mixdog 0.9.119 → 0.9.121
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/package.json +1 -1
- package/src/rules/shared/01-tool.md +11 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +63 -3
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +10 -3
- package/src/runtime/shared/background-tasks.mjs +12 -0
- package/src/standalone/session-service.mjs +18 -2
package/package.json
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
project-relative paths and omit optional scopes equal to its root; explicit
|
|
20
20
|
paths may be outside cwd only for targets outside the project.
|
|
21
21
|
- Plan the fewest dependent rounds, then the fewest calls. Known state —
|
|
22
|
-
anything the task supplied, a tool returned
|
|
22
|
+
anything the task supplied, a tool returned (applied patches and envelope
|
|
23
|
+
hints included), or a check already proved —
|
|
23
24
|
is never re-found, re-derived, or re-verified; a change to its subject
|
|
24
25
|
re-opens it. Batch calls iff none needs
|
|
25
26
|
another's output or can change another's inputs/state; otherwise
|
|
@@ -37,16 +38,23 @@
|
|
|
37
38
|
covers only what returned spans cannot, as an anchored offset/limit
|
|
38
39
|
window. A conclusive result ends its facet; evidence that determines the
|
|
39
40
|
answer, edit, or deliverable ends retrieval — patch if needed.
|
|
41
|
+
- Inspect read-only; writable handles, repair, or cleanup only when the
|
|
42
|
+
deliverable requires them, copying what they could destroy. Never clear
|
|
43
|
+
an obstacle or unexpected state by mutating it; unrecoverably lost
|
|
44
|
+
evidence ends its search — report best effort.
|
|
40
45
|
- Once the edit or deliverable is determined, finish in one assistant turn:
|
|
41
46
|
before `apply_patch`, obtain every target hunk's exact current content and
|
|
42
|
-
anchor from `grep`, `code_graph`,
|
|
47
|
+
anchor from `grep`, `code_graph`, `read`, or a prior successful patch
|
|
48
|
+
envelope; never infer patch context from
|
|
43
49
|
another file, a sample, or expected text. Then issue `apply_patch` calls
|
|
44
50
|
serially, never in parallel; use one cohesive call with one file section per
|
|
45
51
|
target, all patches first, then their one batched verification `shell` in
|
|
46
52
|
the same turn — the runtime runs it after every patch and only if all
|
|
47
53
|
succeeded, so verification never needs its own turn. It runs the real
|
|
48
54
|
required postconditions on every changed file and produced artifact,
|
|
49
|
-
never echoing a claim;
|
|
55
|
+
never echoing a claim; changes made through `shell` verify under the same
|
|
56
|
+
one-batch contract — one script proving every postcondition, value-level
|
|
57
|
+
included, never one check per round; a postcondition that did not actually run is
|
|
50
58
|
unresolved, not passed. Retry only failed envelopes; rerun a failed check only
|
|
51
59
|
after a change that can alter its outcome — commands alike; else switch
|
|
52
60
|
route or report it unresolved.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
|
|
2
|
-
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
2
|
+
import { accessSync, constants as fsConstants, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { constants as osConstants, tmpdir } from 'node:os';
|
|
4
|
+
import { delimiter as pathDelimiter } from 'node:path';
|
|
4
5
|
import { join as pathJoin } from 'node:path';
|
|
5
6
|
import { isLegitimateShellExit } from '../../session/result-classification.mjs';
|
|
6
7
|
import { makeToolEnvelope } from '../../session/tool-envelope.mjs';
|
|
@@ -246,10 +247,69 @@ export function _shellFailureStatus(result, timeout) {
|
|
|
246
247
|
? `[timeout: ${timeout}ms${signalDetail || ' signal: unknown'}${causeDetail}]${timeoutHint}`
|
|
247
248
|
: (signal
|
|
248
249
|
? `[signal: ${signal}${causeDetail}]`
|
|
249
|
-
: (exitCode !== 0 && exitCode !== null ? `[exit code: ${exitCode}]` : '')));
|
|
250
|
+
: (exitCode !== 0 && exitCode !== null ? `[exit code: ${exitCode}]${_exitClassDiagnostic(exitCode, result.stderr)}` : '')));
|
|
250
251
|
return { signal, exitCode, shellToolFailed, statusDetail };
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// Deterministic POSIX exit-class facts only (127 not-found, 126 not
|
|
255
|
+
// executable, 128+N signal). Per-command meanings (grep 1 = no match, test
|
|
256
|
+
// runner 1 = failures) stay uninterpreted — that would need a per-command
|
|
257
|
+
// dictionary, which is banned steering. 127 additionally names verified
|
|
258
|
+
// same-prefix executables actually present on PATH (fact statement, no
|
|
259
|
+
// substitution suggestion) so the model skips the "then what exists?" probe.
|
|
260
|
+
const _SIGNAL_NAME_BY_NUMBER = new Map(
|
|
261
|
+
Object.entries(osConstants.signals || {}).map(([name, num]) => [num, name]).reverse(),
|
|
262
|
+
);
|
|
263
|
+
function _missingCommandFrom(stderr) {
|
|
264
|
+
const text = String(stderr || '');
|
|
265
|
+
const m = /(?:^|\n)[^\n]*?(?:line \d+:\s*)?([A-Za-z0-9._+-]+):\s*(?:command )?not found/.exec(text)
|
|
266
|
+
|| /The term '([^']+)' is not recognized/.exec(text);
|
|
267
|
+
return m ? m[1] : null;
|
|
268
|
+
}
|
|
269
|
+
function _pathPrefixExecutables(cmd, limit = 5) {
|
|
270
|
+
const needle = String(cmd || '').toLowerCase();
|
|
271
|
+
const hits = [];
|
|
272
|
+
if (!needle) return hits;
|
|
273
|
+
const seenDirs = new Set();
|
|
274
|
+
for (const dir of String(process.env.PATH || '').split(pathDelimiter)) {
|
|
275
|
+
if (!dir || seenDirs.has(dir)) continue;
|
|
276
|
+
seenDirs.add(dir);
|
|
277
|
+
if (seenDirs.size > 64) break;
|
|
278
|
+
let entries;
|
|
279
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
280
|
+
for (const ent of entries) {
|
|
281
|
+
if (ent.isDirectory()) continue;
|
|
282
|
+
if (!ent.name.toLowerCase().startsWith(needle)) continue;
|
|
283
|
+
if (process.platform !== 'win32') {
|
|
284
|
+
try { accessSync(pathJoin(dir, ent.name), fsConstants.X_OK); } catch { continue; }
|
|
285
|
+
}
|
|
286
|
+
hits.push(`${ent.name} (${dir.replace(/\\/g, '/')})`);
|
|
287
|
+
if (hits.length >= limit) return hits;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return hits;
|
|
291
|
+
}
|
|
292
|
+
export function _exitClassDiagnostic(exitCode, stderr) {
|
|
293
|
+
// Not gated on 127: compound chains (`a && b; c`) and pipelines mask the
|
|
294
|
+
// 127 into the chain's final code (observed as exit 1 in 6/28 bench
|
|
295
|
+
// cases), so the stderr fact decides, not the exit code.
|
|
296
|
+
{
|
|
297
|
+
const cmd = _missingCommandFrom(stderr);
|
|
298
|
+
if (cmd) {
|
|
299
|
+
const hits = _pathPrefixExecutables(cmd);
|
|
300
|
+
return hits.length
|
|
301
|
+
? ` — '${cmd}' is not on PATH; PATH does have: ${hits.join(', ')}`
|
|
302
|
+
: ` — '${cmd}' is not on PATH and no '${cmd}*' executable exists on PATH`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (exitCode === 126) return ' — 126: command found but not executable (permission or format)';
|
|
306
|
+
if (exitCode > 128 && exitCode < 165) {
|
|
307
|
+
const name = _SIGNAL_NAME_BY_NUMBER.get(exitCode - 128);
|
|
308
|
+
if (name) return ` — 128+${exitCode - 128}: terminated by ${name}`;
|
|
309
|
+
}
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
|
|
253
313
|
export function _composeShellFailure(statusMarker, errorPrefix, warningBlock, payload) {
|
|
254
314
|
return `${errorPrefix}${statusMarker}${warningBlock ? `\n${warningBlock}` : ''}\n\n${payload}`;
|
|
255
315
|
}
|
|
@@ -76,6 +76,9 @@ export function findFileByBasename(searchRoot, fullPath, { limit = 3, maxDirs =
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// Queue drained without hitting maxDirs/limit → the walk covered the
|
|
80
|
+
// whole (non-vendor, non-hidden) tree; a miss is conclusive.
|
|
81
|
+
matches.exhaustive = queue.length === 0;
|
|
79
82
|
return matches;
|
|
80
83
|
} catch { return []; }
|
|
81
84
|
}
|
|
@@ -153,6 +156,7 @@ export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDi
|
|
|
153
156
|
}
|
|
154
157
|
}
|
|
155
158
|
}
|
|
159
|
+
matches.exhaustive = queue.length === 0;
|
|
156
160
|
return matches;
|
|
157
161
|
} catch { return []; }
|
|
158
162
|
}
|
|
@@ -67,6 +67,11 @@ const NOT_FOUND_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
|
67
67
|
|
|
68
68
|
const ENOENT_FIND_NUDGE = 'Locate with find on the basename before retrying.';
|
|
69
69
|
|
|
70
|
+
// Exhaustive-scan miss: the basename BFS drained its queue (whole non-vendor,
|
|
71
|
+
// non-hidden tree walked) with zero hits, so a follow-up find/list re-probe
|
|
72
|
+
// cannot succeed. Declare the miss conclusive instead of nudging one.
|
|
73
|
+
const ENOENT_ABSENT = ' Nothing same-named elsewhere in the project scan — treat the path as absent (create it if the task requires).';
|
|
74
|
+
|
|
70
75
|
// Per-invocation memo for the ENOENT recovery fs scans. A single grep/glob
|
|
71
76
|
// ENOENT surfaces the SAME missing path through tryReadFamilyEnoentRedirect
|
|
72
77
|
// (resolveUniqueEnoentRedirect) AND buildNotFoundHint (which re-runs
|
|
@@ -120,7 +125,7 @@ function spaceJoinedPathHint(requestedPath) {
|
|
|
120
125
|
|
|
121
126
|
function appendEnoentFindNudge(text = '') {
|
|
122
127
|
const base = String(text || '');
|
|
123
|
-
if (base.includes(ENOENT_FIND_NUDGE)) return base;
|
|
128
|
+
if (base.includes(ENOENT_FIND_NUDGE) || base.includes(ENOENT_ABSENT)) return base;
|
|
124
129
|
const sep = base.length && !/\s$/.test(base) ? ' ' : '';
|
|
125
130
|
return `${base}${sep}${ENOENT_FIND_NUDGE}`;
|
|
126
131
|
}
|
|
@@ -191,6 +196,7 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
|
|
|
191
196
|
if (dirHits.length === 1) {
|
|
192
197
|
return ` Not found at this path; the same directory name exists at: "${normalizeOutputPath(dirHits[0])}". ${actionVerb} that path directly.`;
|
|
193
198
|
}
|
|
199
|
+
const absent = elsewhere.exhaustive === true && dirHits.exhaustive === true ? ENOENT_ABSENT : '';
|
|
194
200
|
const parentRel = nearestExistingParentRel(workDir, missingPath);
|
|
195
201
|
if (parentRel) {
|
|
196
202
|
const resolvedParent = parentRel === '.' ? workDir : resolveAgainstCwd(parentRel, workDir);
|
|
@@ -200,11 +206,12 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
|
|
|
200
206
|
.slice(0, 3);
|
|
201
207
|
if (siblings.length) {
|
|
202
208
|
const shown = parentRel === '.' ? '.' : normalizeOutputPath(parentRel);
|
|
203
|
-
return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}
|
|
209
|
+
return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.${absent}`;
|
|
204
210
|
}
|
|
205
211
|
}
|
|
212
|
+
return absent;
|
|
206
213
|
}
|
|
207
|
-
return '';
|
|
214
|
+
return elsewhere.exhaustive === true ? ENOENT_ABSENT : '';
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
export function relativePathPrefix(pathPrefix, workDir) {
|
|
@@ -241,6 +241,17 @@ export function getBackgroundTask(taskId, options = {}) {
|
|
|
241
241
|
return task;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/** CC-parity lifetime signal: a session-owned background task remains live
|
|
245
|
+
* work even while the interactive turn is idle and every view is detached. */
|
|
246
|
+
export function hasActiveBackgroundTasks(options = {}) {
|
|
247
|
+
pruneTasks(options);
|
|
248
|
+
const wanted = clean(options.surface);
|
|
249
|
+
return [...tasks.values()].some((task) =>
|
|
250
|
+
task.status === 'running'
|
|
251
|
+
&& (!wanted || task.surface === wanted)
|
|
252
|
+
&& taskMatchesScope(task, options, { includeUnattributed: false }));
|
|
253
|
+
}
|
|
254
|
+
|
|
244
255
|
export function listBackgroundTasks(options = {}) {
|
|
245
256
|
const { surface } = options;
|
|
246
257
|
pruneTasks(options);
|
|
@@ -457,6 +468,7 @@ export function taskSummary(task) {
|
|
|
457
468
|
operation: task.operation,
|
|
458
469
|
label: task.label,
|
|
459
470
|
status: task.status,
|
|
471
|
+
notified: task.notified === true,
|
|
460
472
|
startedAt: task.startedAt,
|
|
461
473
|
finishedAt: task.finishedAt,
|
|
462
474
|
error: task.error,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
materializePromptSubmission,
|
|
21
21
|
preparePromptSubmissionForProvider,
|
|
22
22
|
} from '../runtime/attachments/store.mjs';
|
|
23
|
+
import { hasActiveBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
|
|
23
24
|
|
|
24
25
|
const MAX_CLONE_DEPTH = 24;
|
|
25
26
|
const requireDesktopService = createRequire(import.meta.url);
|
|
@@ -246,6 +247,12 @@ export function createSessionService({
|
|
|
246
247
|
}
|
|
247
248
|
|
|
248
249
|
function sessionBusy(entry) {
|
|
250
|
+
const sessionId = currentSessionId(entry);
|
|
251
|
+
// CC parity: detached views do not make their background commands
|
|
252
|
+
// disposable. Keep the owner runtime (and daemon self-shutdown guard) live
|
|
253
|
+
// until the task reaches a terminal state and its completion can be
|
|
254
|
+
// delivered back into this session.
|
|
255
|
+
if (sessionId && hasActiveBackgroundTasks({ callerSessionId: sessionId })) return true;
|
|
249
256
|
if (typeof entry?.busy === 'boolean') return entry.busy;
|
|
250
257
|
try {
|
|
251
258
|
return updateEntryBusy(entry, entry.runtime.getState?.() || {});
|
|
@@ -256,6 +263,14 @@ export function createSessionService({
|
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
|
|
266
|
+
function liveBusyCount() {
|
|
267
|
+
let count = 0;
|
|
268
|
+
for (const entry of sessions) {
|
|
269
|
+
if (sessionBusy(entry)) count += 1;
|
|
270
|
+
}
|
|
271
|
+
return count;
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
function startEvictionSweep() {
|
|
260
275
|
if (evictTimer || closed) return;
|
|
261
276
|
evictTimer = setInterval(() => {
|
|
@@ -1241,8 +1256,9 @@ export function createSessionService({
|
|
|
1241
1256
|
configureSession, stop, releaseClient,
|
|
1242
1257
|
get size() { return sessions.size; },
|
|
1243
1258
|
/** Live work the daemon must not abandon (self-shutdown guard). */
|
|
1244
|
-
get busyCount() { return
|
|
1259
|
+
get busyCount() { return liveBusyCount(); },
|
|
1245
1260
|
get status() {
|
|
1261
|
+
const busy = liveBusyCount();
|
|
1246
1262
|
let watched = 0;
|
|
1247
1263
|
let retained = 0;
|
|
1248
1264
|
let projected = 0;
|
|
@@ -1253,7 +1269,7 @@ export function createSessionService({
|
|
|
1253
1269
|
}
|
|
1254
1270
|
return {
|
|
1255
1271
|
live: sessions.size,
|
|
1256
|
-
busy
|
|
1272
|
+
busy,
|
|
1257
1273
|
watched,
|
|
1258
1274
|
retained,
|
|
1259
1275
|
projected,
|