mixdog 1.1.0 → 1.1.2
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/README.md +13 -6
- package/package.json +1 -2
- package/scripts/latest-guard.mjs +63 -0
- package/scripts/lib/run-node-tests.mjs +103 -27
- package/scripts/lib/test-failure-summary.mjs +7 -2
- package/scripts/merge-mac-update-feed.mjs +107 -0
- package/scripts/mobile-release.mjs +210 -0
- package/scripts/post-release-check.mjs +353 -0
- package/scripts/publish-asset-release.mjs +43 -0
- package/scripts/release-guard.mjs +63 -0
- package/scripts/release-paths.mjs +39 -2
- package/scripts/test.mjs +29 -7
- package/src/defaults/shipped-history.json +2 -0
- package/src/defaults/skills/computer-use/SKILL.md +1 -1
- package/src/defaults/skills/setup/references/actions.md +1 -0
- package/src/headless-role.mjs +2 -2
- package/src/lib/keychain-cjs.cjs +582 -17
- package/src/lib/rules-builder.cjs +0 -23
- package/src/rules/lead/LEAD.md +2 -2
- package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
- package/src/runtime/agent/orchestrator/config.mjs +18 -0
- package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
- package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
- package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -91
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
- package/src/runtime/channels/index.mjs +1 -5
- package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
- package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
- package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
- package/src/runtime/channels/lib/worker-main.mjs +4 -34
- package/src/runtime/computer-bridge/client.mjs +25 -1
- package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
- package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
- package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
- package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
- package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
- package/src/runtime/effort-judge/judge-input.mjs +5 -2
- package/src/runtime/effort-judge/model-install.mjs +9 -3
- package/src/runtime/effort-judge/model-manifest.json +7 -7
- package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
- package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
- package/src/runtime/shared/config.mjs +50 -2
- package/src/runtime/shared/developer-options.mjs +7 -0
- package/src/runtime/shared/instruction-files-mode.mjs +9 -0
- package/src/runtime/shared/llm/model-catalog.mjs +2 -1
- package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
- package/src/runtime/shared/pristine-execution-contract.json +9 -0
- package/src/runtime/shared/pristine-execution.mjs +1 -1
- package/src/runtime/shared/provider-accounts.mjs +2 -0
- package/src/runtime/shared/resource-admission.mjs +10 -3
- package/src/runtime/shared/schedule-model-ref.mjs +11 -0
- package/src/runtime/shared/semver.d.mts +1 -0
- package/src/runtime/shared/semver.mjs +57 -0
- package/src/runtime/shared/session-approval-wait.mjs +35 -0
- package/src/runtime/shared/shell-display.mjs +4 -2
- package/src/runtime/shared/tool-surface.mjs +2 -2
- package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
- package/src/runtime/shared/update-checker.mjs +2 -31
- package/src/session-runtime/boot/apis.mjs +5 -0
- package/src/session-runtime/boot/begin.mjs +0 -2
- package/src/session-runtime/boot/tools.mjs +5 -4
- package/src/session-runtime/model-recency.mjs +3 -0
- package/src/session-runtime/provider-auth/login.mjs +10 -10
- package/src/session-runtime/provider-auth/setup.mjs +22 -2
- package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
- package/src/session-runtime/provider-readiness.mjs +4 -0
- package/src/session-runtime/resource-mcp-api.mjs +7 -3
- package/src/session-runtime/runtime-bootstrap.mjs +0 -4
- package/src/session-runtime/runtime-facade.mjs +5 -0
- package/src/session-runtime/runtime-paths.mjs +0 -1
- package/src/session-runtime/schedule-session-run.mjs +3 -1
- package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
- package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
- package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
- package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
- package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
- package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
- package/src/session-runtime/services/channel-admin.mjs +4 -2
- package/src/session-runtime/services/channel-worker.mjs +1 -1
- package/src/session-runtime/services/provider-admin.mjs +80 -17
- package/src/session-runtime/services/seeds.mjs +7 -10
- package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
- package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
- package/src/session-runtime/settings-profile-api.mjs +27 -2
- package/src/session-runtime/setup-tool/executor.mjs +5 -0
- package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
- package/src/session-runtime/starter-guide-state.mjs +62 -0
- package/src/session-runtime/tool-policy-refresh.mjs +3 -0
- package/src/session-runtime/webhook-session-run.mjs +3 -1
- package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
- package/src/standalone/channel-binding.mjs +1 -2
- package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
- package/src/standalone/daemon-channels-loader.mjs +2 -3
- package/src/standalone/local-session-runtime.mjs +2 -78
- package/src/standalone/session-protocol.mjs +2 -0
- package/src/tui/app/model-options.mjs +13 -1
- package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
- package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
- package/src/tui/dist/index.mjs +84 -34
- package/src/tui/session/context-state.mjs +2 -0
- package/src/tui/session/live-share/owner.mjs +17 -0
- package/src/tui/session/oauth-flows.mjs +2 -0
- package/src/tui/session/session-api/extensions.mjs +12 -8
- package/src/tui/session/session-api/integrations.mjs +9 -8
- package/src/tui/session/session-api/routes.mjs +2 -2
- package/src/tui/session/session-api/settings.mjs +1 -0
- package/src/tui/session/session-api/shared.mjs +10 -6
- package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
- package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
- package/src/tui/spinner-meta.mjs +26 -10
- package/src/runtime/channels/tool-defs.mjs +0 -17
|
@@ -16,7 +16,13 @@ import { dirname } from 'node:path';
|
|
|
16
16
|
import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
|
|
17
17
|
import { fileVersion } from './file-version.mjs';
|
|
18
18
|
import { assertPathsReachable } from './fs-reachability.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
normalizeInputPath,
|
|
21
|
+
resolveAgainstCwd,
|
|
22
|
+
normalizeOutputPath,
|
|
23
|
+
toDisplayPath,
|
|
24
|
+
urlAsPathMessage,
|
|
25
|
+
} from './path-utils.mjs';
|
|
20
26
|
import {
|
|
21
27
|
isUncPath,
|
|
22
28
|
isWindowsDevicePath,
|
|
@@ -123,6 +129,8 @@ function guardRealTarget(fullPath) {
|
|
|
123
129
|
async function resolveTargetPath(args, workDir) {
|
|
124
130
|
const raw = args?.path ?? args?.file_path;
|
|
125
131
|
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
132
|
+
const urlMessage = urlAsPathMessage(raw);
|
|
133
|
+
if (urlMessage) return { error: `Error: ${urlMessage}` };
|
|
126
134
|
const norm = normalizeInputPath(raw);
|
|
127
135
|
const guardErr = guardWritePath(norm);
|
|
128
136
|
if (guardErr) return { error: guardErr };
|
|
@@ -474,7 +482,21 @@ function readEditTextForDisplay(fullPath, enc) {
|
|
|
474
482
|
|
|
475
483
|
// ── str_replace: the edit target record + the steps that mutate it ──────────
|
|
476
484
|
|
|
477
|
-
const SAME_STRINGS_ERROR =
|
|
485
|
+
const SAME_STRINGS_ERROR =
|
|
486
|
+
'Error: old_string and new_string are exactly the same — no change needed; the file may already contain it, re-read before retrying';
|
|
487
|
+
|
|
488
|
+
// 1-based line numbers where oldStr occurs in content (first 8), for the
|
|
489
|
+
// non-unique-match error.
|
|
490
|
+
function matchLineHint(content, oldStr) {
|
|
491
|
+
const lines = [];
|
|
492
|
+
let total = 0;
|
|
493
|
+
for (let at = content.indexOf(oldStr); at !== -1; at = content.indexOf(oldStr, at + Math.max(1, oldStr.length))) {
|
|
494
|
+
total += 1;
|
|
495
|
+
if (lines.length < 8) lines.push(content.slice(0, at).split('\n').length);
|
|
496
|
+
}
|
|
497
|
+
if (!lines.length) return '';
|
|
498
|
+
return ` at lines ${lines.join(', ')}${total > lines.length ? ', …' : ''}`;
|
|
499
|
+
}
|
|
478
500
|
|
|
479
501
|
function targetSnapshot(statBefore) {
|
|
480
502
|
return statBefore ? { exists: true, ...fileVersion(statBefore) } : { exists: false };
|
|
@@ -635,7 +657,7 @@ async function applyEditInProcess(edit, oldStr, editPlan, replaceAll) {
|
|
|
635
657
|
if (occurrences > 1 && !replaceAll) {
|
|
636
658
|
const message = `old_string found ${occurrences} times`;
|
|
637
659
|
return (
|
|
638
|
-
`Error: edit failed (${message}; pass replace_all:true or extend old_string)` +
|
|
660
|
+
`Error: edit failed (${message}${matchLineHint(content, oldStr)}; pass replace_all:true or extend old_string)` +
|
|
639
661
|
formatEditFailureExcerpt(content, oldStr, message)
|
|
640
662
|
);
|
|
641
663
|
}
|
|
@@ -675,7 +697,10 @@ async function applyEditNative(edit, oldStr, newStr, editPlan, replaceAll) {
|
|
|
675
697
|
const message = err?.message || String(err);
|
|
676
698
|
if (/old_string (?:not found|found \d+ times)/i.test(message)) {
|
|
677
699
|
const latest = readEditTextForDisplay(fullPath, fileEnc) ?? content;
|
|
678
|
-
|
|
700
|
+
const hinted = /found \d+ times/i.test(message)
|
|
701
|
+
? message.replace(/(found \d+ times)/i, `$1${matchLineHint(latest, oldStr)}`)
|
|
702
|
+
: message;
|
|
703
|
+
return `Error: edit failed (${hinted})${formatEditFailureExcerpt(latest, oldStr, message)}`;
|
|
679
704
|
}
|
|
680
705
|
throw err;
|
|
681
706
|
}
|
|
@@ -709,10 +734,37 @@ async function requiresInProcessEdit(edit, editPlan) {
|
|
|
709
734
|
);
|
|
710
735
|
}
|
|
711
736
|
|
|
737
|
+
// Models trained on other CLIs send `old_str`/`new_str`. An alias is honoured
|
|
738
|
+
// only when the canonical key is absent; both present with different values is
|
|
739
|
+
// ambiguous and rejected.
|
|
740
|
+
const STR_REPLACE_ALIASES = [
|
|
741
|
+
['old_string', 'old_str'],
|
|
742
|
+
['new_string', 'new_str'],
|
|
743
|
+
];
|
|
744
|
+
|
|
745
|
+
function resolveStrReplaceArgs(args) {
|
|
746
|
+
const out = {};
|
|
747
|
+
for (const [canonical, alias] of STR_REPLACE_ALIASES) {
|
|
748
|
+
const hasCanonical = args?.[canonical] !== undefined;
|
|
749
|
+
const hasAlias = args?.[alias] !== undefined;
|
|
750
|
+
if (hasCanonical && hasAlias && args[canonical] !== args[alias]) {
|
|
751
|
+
return {
|
|
752
|
+
error: `Error: edit received both ${canonical} and ${alias} with different values; pass only ${canonical}`,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
out[canonical] = hasCanonical ? args[canonical] : args?.[alias];
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
|
|
712
760
|
async function adaptStrReplace(args, workDir, options) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
761
|
+
const resolved = resolveStrReplaceArgs(args);
|
|
762
|
+
if (resolved.error) return resolved.error;
|
|
763
|
+
let oldStr = resolved.old_string;
|
|
764
|
+
let newStr = resolved.new_string;
|
|
765
|
+
if (typeof oldStr !== 'string' || typeof newStr !== 'string') {
|
|
766
|
+
return `Error: edit requires string old_string and new_string (got keys: ${Object.keys(args).join(', ')})`;
|
|
767
|
+
}
|
|
716
768
|
if (oldStr === newStr) return SAME_STRINGS_ERROR;
|
|
717
769
|
const replaceAll = args?.replace_all === true || String(args?.replace_all || '').toLowerCase() === 'true';
|
|
718
770
|
const target = await resolveTargetPath(args, workDir);
|
|
@@ -729,7 +781,8 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
729
781
|
if (read.error) return read.error;
|
|
730
782
|
const edit = { fullPath, workDir, options, ...read };
|
|
731
783
|
if (oldStr.length === 0) {
|
|
732
|
-
if (edit.content.length > 0)
|
|
784
|
+
if (edit.content.length > 0)
|
|
785
|
+
return `Error: cannot create ${fullPath}: file exists: to replace it entirely pass its current content as old_string (read it first); to change part, pass that part as old_string with the replacement as new_string`;
|
|
733
786
|
const stale = await commitEdit(edit, {
|
|
734
787
|
bytes: encodeEditContent(newStr, edit.fileEnc),
|
|
735
788
|
after: newStr,
|
|
@@ -200,10 +200,16 @@ function unwrapQuotedCommand(command) {
|
|
|
200
200
|
|
|
201
201
|
function parseCommand(rawCommand, workDir) {
|
|
202
202
|
const command = unwrapQuotedCommand(rawCommand);
|
|
203
|
-
if (commandHasShellSyntax(command))
|
|
203
|
+
if (commandHasShellSyntax(command)) {
|
|
204
|
+
// commandHasShellSyntax already skips quoted text; the pipe hint follows
|
|
205
|
+
// only when the rejected command really has an unquoted `|`.
|
|
206
|
+
const unquoted = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '');
|
|
204
207
|
throw new Error(
|
|
205
|
-
|
|
208
|
+
`git command must not contain shell operators or substitution; multiple commands belong in the command array.${
|
|
209
|
+
unquoted.includes('|') ? ' For | head/tail use output_limit, for | grep use the grep tool' : ''
|
|
210
|
+
}`
|
|
206
211
|
);
|
|
212
|
+
}
|
|
207
213
|
const tokens = tokenizeDirectArgv(command);
|
|
208
214
|
if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) {
|
|
209
215
|
throw new Error('command must begin with git');
|
|
@@ -802,6 +808,23 @@ function splitChainedGitCommands(command) {
|
|
|
802
808
|
// ("status --short") gets it instead of a rejection. Only whole submitted
|
|
803
809
|
// commands are prefixed; a `&&` piece after the first still has to be a full
|
|
804
810
|
// git command, so non-git chain segments stay refused.
|
|
811
|
+
// A JSON-stringified string[] ('["git status","git log"]') for an array param.
|
|
812
|
+
// No git command or change id starts with `[`, so this never reinterprets a
|
|
813
|
+
// call that could have worked.
|
|
814
|
+
function parseStringifiedArray(value) {
|
|
815
|
+
if (typeof value !== 'string') return value;
|
|
816
|
+
const text = value.trim();
|
|
817
|
+
if (!text.startsWith('[') || !text.endsWith(']')) return value;
|
|
818
|
+
try {
|
|
819
|
+
const parsed = JSON.parse(text);
|
|
820
|
+
return Array.isArray(parsed) && parsed.length > 0 && parsed.every((item) => typeof item === 'string')
|
|
821
|
+
? parsed
|
|
822
|
+
: value;
|
|
823
|
+
} catch {
|
|
824
|
+
return value;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
805
828
|
function withGitPrefix(command) {
|
|
806
829
|
if (typeof command !== 'string') return command;
|
|
807
830
|
const text = String(unwrapQuotedCommand(command)).trim();
|
|
@@ -813,10 +836,12 @@ function withGitPrefix(command) {
|
|
|
813
836
|
export async function executeGitTool(rawInput, workDir, options = {}) {
|
|
814
837
|
if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput))
|
|
815
838
|
return fail('git requires an arguments object');
|
|
839
|
+
const rawCommand = parseStringifiedArray(rawInput.command);
|
|
816
840
|
const input = {
|
|
817
841
|
...rawInput,
|
|
818
|
-
command: Array.isArray(
|
|
842
|
+
command: Array.isArray(rawCommand) ? rawCommand.map(withGitPrefix) : withGitPrefix(rawCommand),
|
|
819
843
|
};
|
|
844
|
+
if (rawInput.change_ids !== undefined) input.change_ids = parseStringifiedArray(rawInput.change_ids);
|
|
820
845
|
if (rawInput.command === undefined) delete input.command;
|
|
821
846
|
const action = input.action ?? 'command';
|
|
822
847
|
if (action === 'stage') {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
4
5
|
import { isWSL } from '../../../../shared/wsl.mjs';
|
|
5
6
|
|
|
6
7
|
// Restore the on-disk casing of a path (win32 only). rg relativizes candidate
|
|
@@ -48,6 +49,13 @@ function windowsPathToPosixPath(winPath) {
|
|
|
48
49
|
return winPath;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// http(s):// URLs are not file paths; the generic path guards would reject
|
|
53
|
+
// them with a misleading ADS/trailing-dot message. Returns an error message or null.
|
|
54
|
+
export function urlAsPathMessage(p) {
|
|
55
|
+
if (typeof p !== 'string' || !/^\s*https?:\/\//i.test(p)) return null;
|
|
56
|
+
return 'this is a URL; use web_fetch';
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
export function normalizeInputPath(p) {
|
|
52
60
|
if (typeof p !== 'string') return p;
|
|
53
61
|
// Trim leading/trailing whitespace — LLMs intermittently emit paths with
|
|
@@ -68,6 +76,10 @@ export function normalizeInputPath(p) {
|
|
|
68
76
|
const looksUnc = out.startsWith('//');
|
|
69
77
|
if (looksPosixDrive || looksCygdrive || looksWsl || looksUnc) {
|
|
70
78
|
out = posixPathToWindowsPath(out);
|
|
79
|
+
} else if (/^\/[a-zA-Z]:[\\/]/.test(out)) {
|
|
80
|
+
// `/C:/x/y` or `/C:\x\y`: a stray leading slash before a drive letter.
|
|
81
|
+
// Left as-is, the guard reads `C:` as a component carrying an NTFS ADS colon.
|
|
82
|
+
out = `${out[1]}:\\${out.slice(4).replace(/\//g, '\\')}`;
|
|
71
83
|
}
|
|
72
84
|
} else if (isWSL()) {
|
|
73
85
|
// Reverse direction: a Windows-style drive path from the host maps to
|
|
@@ -323,6 +335,66 @@ function compactReadRangeTuple(value) {
|
|
|
323
335
|
};
|
|
324
336
|
}
|
|
325
337
|
|
|
338
|
+
const JOINED_PATH_KEYS = { read: ['path', 'file_path'], grep: ['path', 'root'], glob: ['path', 'root'] };
|
|
339
|
+
const JOINED_PATH_LIMIT = 10;
|
|
340
|
+
|
|
341
|
+
// One string that joins several paths with commas/whitespace. A string that
|
|
342
|
+
// is itself an existing path is left alone (so `C:\Program Files\x` works);
|
|
343
|
+
// otherwise, when EVERY piece exists, it becomes a path array. Anything else
|
|
344
|
+
// is left for the normal not-found error (which carries the array hint).
|
|
345
|
+
// A split never widens scope: every piece must resolve strictly INSIDE the
|
|
346
|
+
// session working directory (the project root). `.`, `..`, the directory
|
|
347
|
+
// itself, its ancestors and anything outside it keep the string unsplit and
|
|
348
|
+
// return the array hint instead.
|
|
349
|
+
// Residual ambiguity (accepted): a missing filename that contains spaces or
|
|
350
|
+
// commas, whose pieces all happen to exist as separate in-project paths, is
|
|
351
|
+
// read as those pieces rather than reported missing.
|
|
352
|
+
// Returns an error message when the split is refused or exceeds the batch
|
|
353
|
+
// limit, else null.
|
|
354
|
+
export function splitJoinedPathScopes(toolName, args, workDir) {
|
|
355
|
+
const keys = JOINED_PATH_KEYS[toolName];
|
|
356
|
+
if (!keys || !args || typeof args !== 'object' || Array.isArray(args)) return null;
|
|
357
|
+
const exists = (p) => {
|
|
358
|
+
try {
|
|
359
|
+
return existsSync(resolveAgainstCwd(normalizeInputPath(p), workDir));
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
// Containment is judged on REAL targets: a symlink/junction inside the
|
|
365
|
+
// directory that points outside it is outside.
|
|
366
|
+
const realOrNull = (p) => {
|
|
367
|
+
try {
|
|
368
|
+
return realpathSync.native(p);
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const realRoot = realOrNull(resolve(workDir));
|
|
374
|
+
const strictlyInside = (p) => {
|
|
375
|
+
const piece = normalizeInputPath(p);
|
|
376
|
+
if (piece === '.' || piece === '..' || !realRoot) return false;
|
|
377
|
+
const target = realOrNull(resolveAgainstCwd(piece, workDir));
|
|
378
|
+
if (!target) return false;
|
|
379
|
+
const rel = relative(realRoot, target);
|
|
380
|
+
return Boolean(rel) && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
381
|
+
};
|
|
382
|
+
for (const key of keys) {
|
|
383
|
+
const value = args[key];
|
|
384
|
+
if (typeof value !== 'string' || !/[\s,]/.test(value.trim()) || exists(value)) continue;
|
|
385
|
+
const pieces = value.split(/[\s,]+/).filter(Boolean);
|
|
386
|
+
if (pieces.length < 2 || !pieces.every(exists)) continue;
|
|
387
|
+
if (!pieces.every(strictlyInside)) {
|
|
388
|
+
return `Error: ${toolName} arg "${key}" joins several paths in one string, and one is ".", "..", a parent of the working directory or outside it, so it is not split automatically. To pass several paths, send an array (path[]), not a comma- or space-joined string.`;
|
|
389
|
+
}
|
|
390
|
+
if (pieces.length > JOINED_PATH_LIMIT) {
|
|
391
|
+
return `Error: ${toolName} arg "${key}" joins ${pieces.length} paths in one string (${pieces.length} given, max ${JOINED_PATH_LIMIT}); split or pass the parent directory.`;
|
|
392
|
+
}
|
|
393
|
+
args[key] = pieces;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
326
398
|
export function coerceReadFamilyPathArg(path) {
|
|
327
399
|
// A path string names exactly one operand. Filesystem contents must not
|
|
328
400
|
// change its meaning into a batch, repaired JSON, or another filename.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* none of them opens a body.
|
|
7
7
|
*/
|
|
8
8
|
import * as fsPromises from 'node:fs/promises';
|
|
9
|
-
import { buildNotFoundHint, finalizeReadFamilyEnoentTail } from './search-path-diagnostics.mjs';
|
|
9
|
+
import { appendNotFoundCwdNote, buildNotFoundHint, finalizeReadFamilyEnoentTail } from './search-path-diagnostics.mjs';
|
|
10
10
|
|
|
11
11
|
const GUARD_REASON = {
|
|
12
12
|
// R1: UNC / SMB share (\\server\share, //server/share). Reading these on
|
|
@@ -152,7 +152,7 @@ export function readNotFoundResponse(err, { fullPath, filePath, workDir }, helpe
|
|
|
152
152
|
const { findSimilarFile, normalizeErrorMessage, normalizeOutputPath } = helpers;
|
|
153
153
|
const similar = findSimilarFile(fullPath);
|
|
154
154
|
let hint = similar
|
|
155
|
-
? ` Did you mean "${normalizeOutputPath(similar)}"
|
|
155
|
+
? appendNotFoundCwdNote(` Did you mean "${normalizeOutputPath(similar)}"?`, workDir, err?.code)
|
|
156
156
|
: buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
|
|
157
157
|
hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
|
|
158
158
|
const safeMsg = normalizeErrorMessage(err instanceof Error ? err.message : String(err), workDir);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { executeSingleReadTool } from './read-single-tool.mjs';
|
|
10
10
|
import { readPathStringGuardError } from './read-open.mjs';
|
|
11
|
-
import { coerceReadFamilyPathArg } from './path-utils.mjs';
|
|
11
|
+
import { coerceReadFamilyPathArg, urlAsPathMessage } from './path-utils.mjs';
|
|
12
12
|
import { readIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
|
|
13
13
|
import { currentToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
|
|
14
14
|
import { readReachPreflight } from './read-tool/reach-preflight.mjs';
|
|
@@ -113,6 +113,8 @@ async function executeReadToolBody(
|
|
|
113
113
|
const { normalizeInputPath, normaliseReadLineWindowArgs } = helpers;
|
|
114
114
|
applyFilePathAlias(args);
|
|
115
115
|
args.path = coerceReadFamilyPathArg(args.path);
|
|
116
|
+
const urlMessage = urlAsPathMessage(args.path);
|
|
117
|
+
if (urlMessage) return `Error: ${urlMessage}`;
|
|
116
118
|
// Reachability preflight up front (all shapes) — before
|
|
117
119
|
// readPathStringGuardError / image stat.
|
|
118
120
|
// options._skipReachPreflight: set only by the batch dispatcher on its
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
normalizeOutputPath,
|
|
8
8
|
trueCasePath,
|
|
9
9
|
} from './path-utils.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
_suggestIndexedPaths,
|
|
12
|
+
appendNotFoundCwdNote,
|
|
13
|
+
buildNotFoundHintBody,
|
|
14
|
+
finalizeReadFamilyEnoentTail,
|
|
15
|
+
} from './search-path-diagnostics.mjs';
|
|
11
16
|
import { buildGrepCacheKey, buildGrepRgArgs } from './search-builders.mjs';
|
|
12
17
|
import { runRg, runRgWindowedLines } from './native-search-runner.mjs';
|
|
13
18
|
import { markScopedCacheIncomplete, markScopedCacheUnsafe } from '../../session/cache/scoped-cache-outcome.mjs';
|
|
@@ -133,9 +138,15 @@ async function runCachedGrepSearch(request) {
|
|
|
133
138
|
|
|
134
139
|
return await runScopedSearchInFlight(
|
|
135
140
|
cacheKey,
|
|
136
|
-
({ signal, scopedCacheOutcome }) =>
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
({ signal, scopedCacheOutcome }) =>
|
|
142
|
+
runGrepSearch(
|
|
143
|
+
{
|
|
144
|
+
...request,
|
|
145
|
+
cacheKey,
|
|
146
|
+
options: { ...options, scopedCacheOutcome },
|
|
147
|
+
},
|
|
148
|
+
signal
|
|
149
|
+
),
|
|
139
150
|
{ signal: options?.signal || options?.abortSignal || null, scopes: [grepResolvedPath] },
|
|
140
151
|
options?.scopedCacheOutcome
|
|
141
152
|
);
|
|
@@ -200,9 +211,9 @@ async function runGrepSearch(request, sharedSignal) {
|
|
|
200
211
|
|
|
201
212
|
async function grepMissingPathResult({ workDir, grepResolvedPath, searchPath, executeChildBuiltinTool }, err) {
|
|
202
213
|
const msg = `Error: path does not exist: ${normalizeOutputPath(grepResolvedPath)} (${err?.code || 'ENOENT'})`;
|
|
203
|
-
let hint =
|
|
214
|
+
let hint = buildNotFoundHintBody(workDir, grepResolvedPath, 'Search', err?.code, {});
|
|
204
215
|
if (!hint) hint = await _suggestIndexedPaths(grepResolvedPath, executeChildBuiltinTool, workDir);
|
|
205
|
-
return msg + finalizeReadFamilyEnoentTail(hint, searchPath, err?.code);
|
|
216
|
+
return msg + finalizeReadFamilyEnoentTail(appendNotFoundCwdNote(hint, workDir, err?.code), searchPath, err?.code);
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
// Per-call search scope on top of the resolved request: the stat, the
|
|
@@ -152,10 +152,10 @@ function nearestExistingParentRel(workDir, missingPath) {
|
|
|
152
152
|
function spaceJoinedPathHint(requestedPath) {
|
|
153
153
|
if (typeof requestedPath !== 'string') return '';
|
|
154
154
|
const trimmed = requestedPath.trim();
|
|
155
|
-
if (
|
|
156
|
-
const segments = trimmed.split(
|
|
155
|
+
if (!/[\s,]/.test(trimmed)) return '';
|
|
156
|
+
const segments = trimmed.split(/[\s,]+/).filter(Boolean);
|
|
157
157
|
if (segments.length < 2) return '';
|
|
158
|
-
return '
|
|
158
|
+
return ' To pass several paths, send an array (path[]), not a comma- or space-joined string.';
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
function appendEnoentFindNudge(text = '') {
|
|
@@ -167,16 +167,34 @@ function appendEnoentFindNudge(text = '') {
|
|
|
167
167
|
|
|
168
168
|
export function finalizeReadFamilyEnoentTail(hint, requestedPath, errCode = 'ENOENT') {
|
|
169
169
|
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return String(hint || '');
|
|
170
|
-
|
|
170
|
+
const multiPathHint = spaceJoinedPathHint(requestedPath);
|
|
171
|
+
// A multi-path-looking input is not a basename to locate; the array hint wins.
|
|
172
|
+
if (multiPathHint) return String(hint || '') + multiPathHint;
|
|
173
|
+
return appendEnoentFindNudge(String(hint || ''));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The base the missing relative path was resolved against (a `cd` in an earlier
|
|
177
|
+
// shell call never moves it), appended to every not-found hint, empty or not.
|
|
178
|
+
export function appendNotFoundCwdNote(hint, workDir, errCode = 'ENOENT') {
|
|
179
|
+
if (!workDir || !NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return hint;
|
|
180
|
+
return `${hint} (relative paths resolve from ${normalizeOutputPath(workDir)})`;
|
|
171
181
|
}
|
|
172
182
|
|
|
173
183
|
export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'ENOENT', cache = null) {
|
|
184
|
+
return appendNotFoundCwdNote(
|
|
185
|
+
buildNotFoundHintBody(workDir, missingPath, actionVerb, errCode, cache),
|
|
186
|
+
workDir,
|
|
187
|
+
errCode
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function buildNotFoundHintBody(workDir, missingPath, actionVerb, errCode = 'ENOENT', cache = null) {
|
|
174
192
|
if (!NOT_FOUND_CODES.has(String(errCode || 'ENOENT'))) return '';
|
|
175
193
|
// `C:tmpsmp` shape: a Windows path whose separators were eaten by JSON
|
|
176
194
|
// escaping (`"C:\\tmp\\smp"` written as `"C:\tmp\smp"`). Name the cause —
|
|
177
195
|
// the sibling/basename hints below can never resolve such a path.
|
|
178
196
|
const rawMissing = String(missingPath || '');
|
|
179
|
-
if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing
|
|
197
|
+
if (/^[A-Za-z]:(?![\\/])[^\\/]/.test(rawMissing)) {
|
|
180
198
|
return ` The path has a drive letter but no separator after it — the backslashes were probably lost in escaping. Re-issue with forward slashes (e.g. "C:/tmp/smp").`;
|
|
181
199
|
}
|
|
182
200
|
if (missingPathIsOutsideProject(workDir, missingPath)) {
|
|
@@ -45,10 +45,11 @@ export function buildJobNotFoundMessage(jobId) {
|
|
|
45
45
|
return `Error: task not found: ${jobId}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Running promoted jobs, for the runtime-wide read-only counts `task list`
|
|
49
|
+
// shows; each leaves when its native task settles (trackShellJobRecord).
|
|
50
|
+
const promotedJobIds = new Set();
|
|
48
51
|
const backgroundShellJobWatchers = new Map();
|
|
49
52
|
const jobNotifyCtxByJobId = new Map();
|
|
50
|
-
const shellJobResourceLeases = new Map();
|
|
51
|
-
const shellJobResourceLeaseSubscriptions = new Map();
|
|
52
53
|
|
|
53
54
|
function demoteBackgroundShellPriority(pid) {
|
|
54
55
|
if (process.env.MIXDOG_BG_PRIORITY === '0') return;
|
|
@@ -72,27 +73,13 @@ function trackShellJobRecord(task, options) {
|
|
|
72
73
|
try {
|
|
73
74
|
unsubscribe?.();
|
|
74
75
|
} catch {}
|
|
76
|
+
promotedJobIds.delete(jobId);
|
|
75
77
|
void completeShellJobRecord(jobId, normalizeShellJobDetail(detail));
|
|
76
78
|
};
|
|
77
79
|
unsubscribe = subscribeNativeTask(jobId, settle);
|
|
78
80
|
settle(getNativeTask(jobId) || task);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
function releaseShellJobResourceLease(jobId) {
|
|
82
|
-
const unsubscribe = shellJobResourceLeaseSubscriptions.get(jobId);
|
|
83
|
-
shellJobResourceLeaseSubscriptions.delete(jobId);
|
|
84
|
-
try {
|
|
85
|
-
unsubscribe?.();
|
|
86
|
-
} catch {}
|
|
87
|
-
const lease = shellJobResourceLeases.get(jobId);
|
|
88
|
-
if (!lease) return false;
|
|
89
|
-
shellJobResourceLeases.delete(jobId);
|
|
90
|
-
try {
|
|
91
|
-
Promise.resolve(lease.release()).catch(() => {});
|
|
92
|
-
} catch {}
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
83
|
/** Native task status → background-task status. A non-zero exit is a COMMAND
|
|
97
84
|
* result, not a task failure: the identical run in the foreground reports
|
|
98
85
|
* `[exit code: N]` and stays a success, so crossing the promotion threshold
|
|
@@ -262,24 +249,6 @@ export function killShellJob(jobId) {
|
|
|
262
249
|
return { ...attachJobInsights(normalizeShellJobDetail(detail)), killed: wasRunning };
|
|
263
250
|
}
|
|
264
251
|
|
|
265
|
-
export function attachShellJobResourceLease(jobId, lease) {
|
|
266
|
-
if (!jobId || !lease || typeof lease.release !== 'function') return false;
|
|
267
|
-
const detail = getNativeTask(jobId);
|
|
268
|
-
if (detail?.status !== 'running') {
|
|
269
|
-
try {
|
|
270
|
-
Promise.resolve(lease.release()).catch(() => {});
|
|
271
|
-
} catch {}
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
releaseShellJobResourceLease(jobId);
|
|
275
|
-
shellJobResourceLeases.set(jobId, lease);
|
|
276
|
-
const unsubscribe = subscribeNativeTask(jobId, (task) => {
|
|
277
|
-
if (task?.status !== 'running') releaseShellJobResourceLease(jobId);
|
|
278
|
-
});
|
|
279
|
-
shellJobResourceLeaseSubscriptions.set(jobId, unsubscribe);
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
252
|
export function clearShellJobNotifyCtx(jobId) {
|
|
284
253
|
jobNotifyCtxByJobId.delete(jobId);
|
|
285
254
|
}
|
|
@@ -319,7 +288,6 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
|
|
|
319
288
|
unsubscribe();
|
|
320
289
|
backgroundShellJobWatchers.delete(jobId);
|
|
321
290
|
jobNotifyCtxByJobId.delete(jobId);
|
|
322
|
-
releaseShellJobResourceLease(jobId);
|
|
323
291
|
const enriched = attachJobInsights(normalizeShellJobDetail(detail));
|
|
324
292
|
const completion = buildShellCompletion(jobId, enriched);
|
|
325
293
|
const completedTask = completeBackgroundTask(jobId, {
|
|
@@ -407,10 +375,26 @@ export async function promoteForegroundShellJob({
|
|
|
407
375
|
native.stdoutPath = stdoutPath || null;
|
|
408
376
|
native.stderrPath = stderrPath || null;
|
|
409
377
|
}
|
|
378
|
+
// Added before tracking: the record's settlement removes it again.
|
|
379
|
+
promotedJobIds.add(native.jobId);
|
|
410
380
|
trackShellJobRecord(native, { ownerSessionId, clientHostPid, startedAtMs: started });
|
|
411
381
|
return native;
|
|
412
382
|
}
|
|
413
383
|
|
|
384
|
+
/** Running background shell jobs owned by sessions outside `ownSessionIds`. */
|
|
385
|
+
export function otherSessionBackgroundShellJobCount(ownSessionIds) {
|
|
386
|
+
let count = 0;
|
|
387
|
+
for (const jobId of [...promotedJobIds]) {
|
|
388
|
+
const task = getNativeTask(jobId);
|
|
389
|
+
if (task?.status !== 'running') {
|
|
390
|
+
promotedJobIds.delete(jobId);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!ownSessionIds.has(String(task.ownerSessionId || ''))) count += 1;
|
|
394
|
+
}
|
|
395
|
+
return count;
|
|
396
|
+
}
|
|
397
|
+
|
|
414
398
|
/** Live record for a command still running in the FOREGROUND. Background jobs
|
|
415
399
|
* publish through trackShellJobRecord and settle into a terminal record; a
|
|
416
400
|
* foreground command has no task id and no completion notice, so its record
|
|
@@ -518,7 +502,10 @@ export async function shutdownShellJobs(_reason = 'runtime-close', { scope = nul
|
|
|
518
502
|
jobNotifyCtxByJobId.delete(task.jobId);
|
|
519
503
|
}
|
|
520
504
|
const settled = await Promise.all(jobs.map((task) => waitNativeTask(task.jobId, 1_200).catch(() => null)));
|
|
521
|
-
|
|
505
|
+
// A process still running after the wait is not forgotten: it keeps being
|
|
506
|
+
// stopped until its exit is confirmed.
|
|
507
|
+
const unconfirmed = jobs.filter((_, index) => settled[index]?.status === 'running').map((task) => task.jobId);
|
|
508
|
+
for (const jobId of unconfirmed) retryShutdownSurvivor(jobId, 1);
|
|
522
509
|
if (scoped) {
|
|
523
510
|
// The owning session is gone: nothing can query its tasks again, so
|
|
524
511
|
// drop ALL of its retained state — not only the ones that happened to
|
|
@@ -532,16 +519,79 @@ export async function shutdownShellJobs(_reason = 'runtime-close', { scope = nul
|
|
|
532
519
|
} catch {}
|
|
533
520
|
}
|
|
534
521
|
}
|
|
535
|
-
if (!scoped)
|
|
536
|
-
for (const jobId of [...shellJobResourceLeases.keys()]) {
|
|
537
|
-
releaseShellJobResourceLease(jobId);
|
|
538
|
-
}
|
|
539
|
-
jobNotifyCtxByJobId.clear();
|
|
540
|
-
}
|
|
522
|
+
if (!scoped) jobNotifyCtxByJobId.clear();
|
|
541
523
|
return {
|
|
542
524
|
killed: jobs.length,
|
|
543
525
|
cancelledJobs: jobs.length,
|
|
544
526
|
confirmedJobs: settled.filter((task) => task && task.status !== 'running').length,
|
|
527
|
+
unconfirmedJobs: unconfirmed,
|
|
545
528
|
cancelledWatchers,
|
|
546
529
|
};
|
|
547
530
|
}
|
|
531
|
+
|
|
532
|
+
// Shutdown survivors: re-issue the stop every SURVIVOR_RETRY_MS until the
|
|
533
|
+
// native runner confirms the exit, at most SURVIVOR_MAX_ATTEMPTS times, each
|
|
534
|
+
// step logged. A survivor that outlives every attempt stays listed.
|
|
535
|
+
const SURVIVOR_RETRY_MS = 5_000;
|
|
536
|
+
const SURVIVOR_MAX_ATTEMPTS = 10;
|
|
537
|
+
const shutdownSurvivors = new Map();
|
|
538
|
+
|
|
539
|
+
function logShellJobs(message) {
|
|
540
|
+
try {
|
|
541
|
+
process.stderr.write(`[shell-jobs] ${message}\n`);
|
|
542
|
+
} catch {}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function dropShutdownSurvivor(jobId) {
|
|
546
|
+
const entry = shutdownSurvivors.get(jobId);
|
|
547
|
+
if (!entry) return;
|
|
548
|
+
shutdownSurvivors.delete(jobId);
|
|
549
|
+
clearTimeout(entry.timer);
|
|
550
|
+
try {
|
|
551
|
+
entry.unsubscribe();
|
|
552
|
+
} catch {}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function retryShutdownSurvivor(jobId, attempts) {
|
|
556
|
+
let entry = shutdownSurvivors.get(jobId);
|
|
557
|
+
if (entry) clearTimeout(entry.timer);
|
|
558
|
+
else {
|
|
559
|
+
entry = { attempts, timer: null, unsubscribe: () => {} };
|
|
560
|
+
shutdownSurvivors.set(jobId, entry);
|
|
561
|
+
// The native terminal event confirms the exit at any time — also after
|
|
562
|
+
// the last retry — and drops the entry.
|
|
563
|
+
entry.unsubscribe = subscribeNativeTask(jobId, (task) => {
|
|
564
|
+
if (task?.status === 'running') return;
|
|
565
|
+
dropShutdownSurvivor(jobId);
|
|
566
|
+
logShellJobs(`${jobId} exit confirmed after shutdown`);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
entry.attempts = attempts;
|
|
570
|
+
logShellJobs(`shutdown could not confirm ${jobId} exited (attempt ${attempts}); retrying`);
|
|
571
|
+
entry.timer = setTimeout(() => {
|
|
572
|
+
if (getNativeTask(jobId)?.status !== 'running') {
|
|
573
|
+
dropShutdownSurvivor(jobId);
|
|
574
|
+
logShellJobs(`${jobId} exit confirmed after shutdown`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (attempts >= SURVIVOR_MAX_ATTEMPTS) {
|
|
578
|
+
entry.timer = null;
|
|
579
|
+
logShellJobs(`giving up stopping ${jobId} after ${attempts} attempts; it is still running`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
cancelNativeTask(jobId);
|
|
584
|
+
} catch {}
|
|
585
|
+
retryShutdownSurvivor(jobId, attempts + 1);
|
|
586
|
+
}, SURVIVOR_RETRY_MS);
|
|
587
|
+
entry.timer.unref?.();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Shutdown survivors whose termination is not yet confirmed. */
|
|
591
|
+
export function shellShutdownSurvivors() {
|
|
592
|
+
// Reconcile against native state: an exit whose event was missed still ends the entry.
|
|
593
|
+
for (const jobId of [...shutdownSurvivors.keys()]) {
|
|
594
|
+
if (getNativeTask(jobId)?.status !== 'running') dropShutdownSurvivor(jobId);
|
|
595
|
+
}
|
|
596
|
+
return [...shutdownSurvivors].map(([jobId, entry]) => ({ jobId, attempts: entry.attempts }));
|
|
597
|
+
}
|