@azure-id/orc 0.55.2 → 0.56.1
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/CHANGELOG.md +2218 -2064
- package/README-id.md +726 -545
- package/README.md +188 -9
- package/bin/cli.js +849 -44
- package/bin/webui/i18n/en/overview.json +90 -89
- package/bin/webui/i18n/id/overview.json +90 -89
- package/bin/webui/js/03-md.js +9 -3
- package/bin/webui/js/panels/overview.js +453 -447
- package/package.json +39 -39
- package/templates/skills/_shared/extra-dispatch.md +135 -1
- package/templates/skills/orc-quick/SKILL.md +313 -313
- package/templates/skills/orc-quick/references/dispatch-gate.md +10 -1
package/bin/cli.js
CHANGED
|
@@ -22,11 +22,19 @@ const ui = require("./ui.js");
|
|
|
22
22
|
const { SECTIONS: ONBOARDING } = require("./onboarding-content.js");
|
|
23
23
|
|
|
24
24
|
// Where `orc upgrade` fetches a fresh package from. Override with --from <spec>
|
|
25
|
-
// or ORC_INSTALL_SPEC (e.g. a fork
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// or ORC_INSTALL_SPEC (e.g. a fork or a tarball URL).
|
|
26
|
+
//
|
|
27
|
+
// ORDER (v0.56.0): the npm REGISTRY first, then the tarball (straight HTTPS),
|
|
28
|
+
// then the github: spec last. The registry is the published home of this
|
|
29
|
+
// package and is the only source that resolves a VERSION rather than a branch
|
|
30
|
+
// tip; the github: spec shells out to git and fails on machines with restricted
|
|
31
|
+
// git / NVM quirks, so it stays last.
|
|
32
|
+
//
|
|
33
|
+
// PKG_NAME is this package's published name. It is NOT cosmetic: it moved from
|
|
34
|
+
// the unscoped `orc` to the scoped `@azure-id/orc`, and that rename is what
|
|
35
|
+
// broke every upgrade path in the field — see LEGACY_BIN_OWNERS below.
|
|
36
|
+
const PKG_NAME = "@azure-id/orc";
|
|
37
|
+
const NPM_SPEC = PKG_NAME;
|
|
30
38
|
const GITHUB_SPEC = "github:azure-id/orc";
|
|
31
39
|
|
|
32
40
|
const PKG_ROOT = path.join(__dirname, "..");
|
|
@@ -655,12 +663,109 @@ function targetFlags() {
|
|
|
655
663
|
const TARBALL_SPEC =
|
|
656
664
|
"https://github.com/azure-id/orc/archive/refs/heads/main.tar.gz";
|
|
657
665
|
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// The bin-shim collision (v0.56.0) — why every upgrade path died at once.
|
|
668
|
+
//
|
|
669
|
+
// This package was published as the unscoped `orc` and is now `@azure-id/orc`.
|
|
670
|
+
// Both declare the SAME bin name, `orc`. npm links a bin only if the shim is
|
|
671
|
+
// unowned or owned by the package doing the linking, so with the old `orc`
|
|
672
|
+
// package still on disk globally, installing the new scoped one fails with:
|
|
673
|
+
//
|
|
674
|
+
// npm error code EEXIST
|
|
675
|
+
// npm error path <prefix>\orc
|
|
676
|
+
// npm error File exists: <prefix>\orc
|
|
677
|
+
//
|
|
678
|
+
// That error is about a FILE, not a source — which is why swapping sources did
|
|
679
|
+
// nothing: the tarball, the github: spec and the registry all failed
|
|
680
|
+
// identically, and `orc upgrade`'s fallback ladder walked all three and then
|
|
681
|
+
// printed npm's wall. `npm i -g -f <spec>` "worked" only because --force
|
|
682
|
+
// overwrites the shim recklessly, leaving the superseded package installed
|
|
683
|
+
// underneath as a ghost that owns nothing and is never updated again.
|
|
684
|
+
//
|
|
685
|
+
// The fix is to remove the package that OWNS the shim before installing, not to
|
|
686
|
+
// force over it. Detection is by ownership, never by name alone: we look for a
|
|
687
|
+
// globally-installed package that is not us and whose `bin` declares `orc`.
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
// Names ORC has shipped under. Ordered oldest-first; PKG_NAME is excluded on
|
|
691
|
+
// purpose — evicting ourselves is how an upgrade uninstalls the tool.
|
|
692
|
+
const LEGACY_BIN_OWNERS = ["orc"];
|
|
693
|
+
|
|
694
|
+
// `npm root -g`, cached for the life of the process (it shells out).
|
|
695
|
+
let _npmRootG;
|
|
696
|
+
function npmRootGlobal() {
|
|
697
|
+
if (_npmRootG !== undefined) return _npmRootG;
|
|
698
|
+
const r = spawnSync("npm root -g", { shell: true, encoding: "utf8" });
|
|
699
|
+
_npmRootG = r.status === 0 && r.stdout ? r.stdout.trim() : null;
|
|
700
|
+
return _npmRootG;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Read <global node_modules>/<name>/package.json, or null.
|
|
704
|
+
function globalPkgManifest(name) {
|
|
705
|
+
const root = npmRootGlobal();
|
|
706
|
+
if (!root) return null;
|
|
707
|
+
try {
|
|
708
|
+
const f = path.join(root, ...name.split("/"), "package.json");
|
|
709
|
+
return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
710
|
+
} catch (_) {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Any globally-installed package that is NOT this one but declares the `orc`
|
|
716
|
+
// bin — i.e. the thing holding the shim hostage. Returns {name, version, dir}
|
|
717
|
+
// or null. Fail-silent: if npm cannot be asked, we simply do not know, and
|
|
718
|
+
// "unknown" must never be reported as "clean".
|
|
719
|
+
function detectLegacyBinOwner() {
|
|
720
|
+
for (const name of LEGACY_BIN_OWNERS) {
|
|
721
|
+
if (name === PKG_NAME) continue;
|
|
722
|
+
const m = globalPkgManifest(name);
|
|
723
|
+
if (!m) continue;
|
|
724
|
+
// Same name AND same identity is not legacy — a package can legitimately
|
|
725
|
+
// still be called `orc` on a machine that never saw the rename land.
|
|
726
|
+
if (m.name === PKG_NAME) continue;
|
|
727
|
+
const bins = typeof m.bin === "string" ? { [m.name]: m.bin } : m.bin || {};
|
|
728
|
+
if (!Object.prototype.hasOwnProperty.call(bins, "orc")) continue;
|
|
729
|
+
return {
|
|
730
|
+
name,
|
|
731
|
+
version: m.version || "unknown",
|
|
732
|
+
dir: path.join(npmRootGlobal() || "", ...name.split("/")),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Remove the legacy owner so the scoped package can link `orc` cleanly.
|
|
739
|
+
// Returns { ok, output }. This is the ONLY global npm mutation ORC makes on the
|
|
740
|
+
// user's behalf, and it is announced before it runs.
|
|
741
|
+
function evictLegacyBinOwner(owner) {
|
|
742
|
+
console.log(
|
|
743
|
+
` → npm uninstall -g ${owner.name} (legacy ${owner.name}@${owner.version} owns the \`orc\` command)`
|
|
744
|
+
);
|
|
745
|
+
const r = spawnSync(`npm uninstall -g ${owner.name}`, { shell: true, encoding: "utf8" });
|
|
746
|
+
return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Does npm's output describe the bin-shim collision? Matching on the CODE plus
|
|
750
|
+
// the shim path keeps this from firing on an unrelated EEXIST deeper in a tree.
|
|
751
|
+
function isBinShimCollision(output) {
|
|
752
|
+
const o = String(output || "");
|
|
753
|
+
if (!/EEXIST/.test(o)) return false;
|
|
754
|
+
// Normalise separators first so the check needs no backslash character
|
|
755
|
+
// class, then require a path component that IS the bin name (plus the two
|
|
756
|
+
// Windows shim extensions). Matching the code alone would fire on an
|
|
757
|
+
// unrelated EEXIST deeper in a dependency tree.
|
|
758
|
+
const norm = o.split(String.fromCharCode(92)).join("/");
|
|
759
|
+
return new RegExp("/orc([.]cmd|[.]ps1)?([^A-Za-z0-9_-]|$)", "im").test(norm);
|
|
760
|
+
}
|
|
761
|
+
|
|
658
762
|
// Try `npm install -g <spec>`; return { ok, output }. Captures stdio (pipe)
|
|
659
763
|
// instead of inheriting it, so a failed probe with a remaining fallback stays
|
|
660
764
|
// quiet — the loud npm error wall is only shown if EVERY spec fails.
|
|
661
|
-
function npmInstallGlobal(spec) {
|
|
662
|
-
|
|
663
|
-
|
|
765
|
+
function npmInstallGlobal(spec, opts) {
|
|
766
|
+
const force = opts && opts.force ? " --force" : "";
|
|
767
|
+
console.log(" → npm install -g " + spec + force);
|
|
768
|
+
const r = spawnSync(`npm install -g ${spec}${force}`, { shell: true, encoding: "utf8" });
|
|
664
769
|
return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") };
|
|
665
770
|
}
|
|
666
771
|
|
|
@@ -688,11 +793,25 @@ function writeLastGoodSpec(spec) {
|
|
|
688
793
|
// code regardless of how PATH resolves `orc` (important under NVM, where the
|
|
689
794
|
// running shim and the global prefix can differ). Falls back to null if the path
|
|
690
795
|
// can't be determined — the caller then spawns `orc` by name.
|
|
796
|
+
//
|
|
797
|
+
// v0.56.0: the SCOPED directory is checked first. This looked only under
|
|
798
|
+
// `<root>/orc`, which after the rename is the LEGACY package — so on a machine
|
|
799
|
+
// mid-rename it resolved a path that existed, and step 2 re-applied the OLD
|
|
800
|
+
// templates from the very package step 1 had just superseded. A hit is accepted
|
|
801
|
+
// only if the manifest there actually says PKG_NAME; a directory that exists is
|
|
802
|
+
// not proof of identity.
|
|
691
803
|
function freshCliPath() {
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
const
|
|
695
|
-
|
|
804
|
+
const root = npmRootGlobal();
|
|
805
|
+
if (!root) return null;
|
|
806
|
+
for (const name of [PKG_NAME, ...LEGACY_BIN_OWNERS]) {
|
|
807
|
+
const dir = path.join(root, ...name.split("/"));
|
|
808
|
+
const cli = path.join(dir, "bin", "cli.js");
|
|
809
|
+
if (!fs.existsSync(cli)) continue;
|
|
810
|
+
const m = globalPkgManifest(name);
|
|
811
|
+
if (m && m.name !== PKG_NAME) continue;
|
|
812
|
+
return cli;
|
|
813
|
+
}
|
|
814
|
+
return null;
|
|
696
815
|
}
|
|
697
816
|
|
|
698
817
|
// `orc upgrade` = fetch the latest package from the source, THEN apply it.
|
|
@@ -706,25 +825,60 @@ function upgrade() {
|
|
|
706
825
|
const fromFlag = typeof flag("--from") === "string" ? flag("--from") : null;
|
|
707
826
|
// Specs to try in order. `--from` and ORC_INSTALL_SPEC still win OUTRIGHT
|
|
708
827
|
// (single spec, no fallback). Otherwise: the remembered last_good_spec first
|
|
709
|
-
// (if any), then the tarball (straight HTTPS
|
|
710
|
-
// github: spec last. Deduped so a remembered
|
|
828
|
+
// (if any), then the npm REGISTRY, then the tarball (straight HTTPS), then
|
|
829
|
+
// the github: spec last. Deduped so a remembered spec doesn't retry twice.
|
|
711
830
|
let specs;
|
|
712
831
|
if (fromFlag) specs = [fromFlag];
|
|
713
832
|
else if (process.env.ORC_INSTALL_SPEC) specs = [process.env.ORC_INSTALL_SPEC];
|
|
714
833
|
else {
|
|
715
834
|
const remembered = readLastGoodSpec();
|
|
716
|
-
specs = [...new Set([...(remembered ? [remembered] : []), TARBALL_SPEC, GITHUB_SPEC])];
|
|
835
|
+
specs = [...new Set([...(remembered ? [remembered] : []), NPM_SPEC, TARBALL_SPEC, GITHUB_SPEC])];
|
|
717
836
|
}
|
|
718
837
|
|
|
719
838
|
console.log("\norc upgrade — fetching the latest package, then applying it.");
|
|
720
839
|
console.log(" step 1/2: refresh the global orc package");
|
|
721
840
|
|
|
841
|
+
// STEP 0 — evict the legacy bin owner. This runs BEFORE any source is tried,
|
|
842
|
+
// because the collision is on the `orc` shim and therefore fails every source
|
|
843
|
+
// identically: walking the whole ladder first would just spend three network
|
|
844
|
+
// round trips arriving at the same EEXIST. Announced, never silent — it is a
|
|
845
|
+
// global npm mutation on the user's machine, and the one ORC makes for them.
|
|
846
|
+
const legacy = detectLegacyBinOwner();
|
|
847
|
+
if (legacy) {
|
|
848
|
+
console.log(
|
|
849
|
+
`\n ⚠ ${legacy.name}@${legacy.version} is installed globally and owns the \`orc\` command.\n` +
|
|
850
|
+
` This package is now ${PKG_NAME}, so npm cannot link \`orc\` while that one\n` +
|
|
851
|
+
" is there — every install source fails with the same EEXIST.\n" +
|
|
852
|
+
" Removing it first."
|
|
853
|
+
);
|
|
854
|
+
const ev = evictLegacyBinOwner(legacy);
|
|
855
|
+
if (!ev.ok) {
|
|
856
|
+
console.log(
|
|
857
|
+
" ⚠ couldn't remove it automatically — continuing anyway (the install\n" +
|
|
858
|
+
" below falls back to --force if npm still reports the collision)."
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
722
863
|
let installed = false;
|
|
723
864
|
let lastOutput = "";
|
|
865
|
+
let usedSpec = null;
|
|
724
866
|
for (let i = 0; i < specs.length; i++) {
|
|
725
|
-
|
|
867
|
+
let res = npmInstallGlobal(specs[i]);
|
|
868
|
+
// A SURVIVING shim collision means an ORPHANED shim — a file npm left
|
|
869
|
+
// behind with no package owning it, so there was nothing for step 0 to
|
|
870
|
+
// uninstall. --force is correct HERE and only here: the file it overwrites
|
|
871
|
+
// belongs to nobody, which is exactly the case --force is for.
|
|
872
|
+
if (!res.ok && isBinShimCollision(res.output)) {
|
|
873
|
+
console.log(
|
|
874
|
+
" ⚠ npm still reports the `orc` command file as taken, and no package\n" +
|
|
875
|
+
" claims it (an orphaned shim). Overwriting just that file."
|
|
876
|
+
);
|
|
877
|
+
res = npmInstallGlobal(specs[i], { force: true });
|
|
878
|
+
}
|
|
726
879
|
if (res.ok) {
|
|
727
880
|
installed = true;
|
|
881
|
+
usedSpec = specs[i];
|
|
728
882
|
writeLastGoodSpec(specs[i]);
|
|
729
883
|
break;
|
|
730
884
|
}
|
|
@@ -742,14 +896,20 @@ function upgrade() {
|
|
|
742
896
|
}
|
|
743
897
|
console.error(
|
|
744
898
|
"\n❌ upgrade failed at step 1 (npm install). Nothing was changed in .claude/.\n" +
|
|
745
|
-
"
|
|
746
|
-
` npm i -g ${
|
|
899
|
+
" Install directly, then apply:\n" +
|
|
900
|
+
` npm i -g ${NPM_SPEC}\n` +
|
|
747
901
|
" orc update" +
|
|
748
902
|
(tflags.length ? " " + tflags.join(" ") : "") +
|
|
749
|
-
"\n"
|
|
903
|
+
"\n" +
|
|
904
|
+
(isBinShimCollision(lastOutput)
|
|
905
|
+
? "\n npm says the `orc` command file is already taken. Clear it, then retry:\n" +
|
|
906
|
+
` npm uninstall -g ${LEGACY_BIN_OWNERS.join(" ")}\n` +
|
|
907
|
+
` npm i -g ${NPM_SPEC}\n`
|
|
908
|
+
: "")
|
|
750
909
|
);
|
|
751
910
|
process.exit(1);
|
|
752
911
|
}
|
|
912
|
+
if (usedSpec) console.log(` ✓ installed from ${usedSpec}`);
|
|
753
913
|
|
|
754
914
|
const tflags = targetFlags();
|
|
755
915
|
console.log(
|
|
@@ -768,7 +928,7 @@ function upgrade() {
|
|
|
768
928
|
" orc update" +
|
|
769
929
|
(tflags.length ? " " + tflags.join(" ") : "") +
|
|
770
930
|
"\n If it still fails, install directly then apply:\n" +
|
|
771
|
-
` npm i -g ${
|
|
931
|
+
` npm i -g ${NPM_SPEC} && orc update` +
|
|
772
932
|
(tflags.length ? " " + tflags.join(" ") : "") +
|
|
773
933
|
"\n"
|
|
774
934
|
);
|
|
@@ -830,6 +990,22 @@ const vModel = tag((raw) => {
|
|
|
830
990
|
: "⚠ below Opus/Fable — every opus-* agent silently falls back to a smaller model (tier ladder).";
|
|
831
991
|
return { value: raw, warn };
|
|
832
992
|
}, { kind: "enum", choices: KNOWN_MODELS });
|
|
993
|
+
// The fallback target for a foreign dispatch that failed. Two WORDS and then a
|
|
994
|
+
// free-text agent name — deliberately open rather than a closed enum, because
|
|
995
|
+
// the agent roster is generated (`bin/build-agents.js`) and a closed list here
|
|
996
|
+
// would go stale the next time a band moves. `orc-` is required so a typo lands
|
|
997
|
+
// as a refusal rather than as a dispatch to a name nothing answers to.
|
|
998
|
+
const vFallbackAgent = tag((raw) => {
|
|
999
|
+
const v = String(raw || "").trim();
|
|
1000
|
+
if (v === "band" || v === "ask") return { value: v };
|
|
1001
|
+
if (!/^orc-[a-z0-9-]+$/.test(v))
|
|
1002
|
+
return { err: 'must be "band", "ask", or an ORC agent name (e.g. orc-executor-opus-5-med)' };
|
|
1003
|
+
return {
|
|
1004
|
+
value: v,
|
|
1005
|
+
warn:
|
|
1006
|
+
"pinned to one agent — this overrides the score table AND a slot's own pinned agent for every fallback, whatever the task. `band` is what keeps a fallback a change of WHO and not a change of tier.",
|
|
1007
|
+
};
|
|
1008
|
+
}, { kind: "text", choices: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"] });
|
|
833
1009
|
const vPath = tag(
|
|
834
1010
|
(raw) => (raw && raw.trim() ? { value: raw } : { err: "must be a non-empty path" }),
|
|
835
1011
|
{ kind: "path" }
|
|
@@ -937,6 +1113,14 @@ const CONFIG_META = [
|
|
|
937
1113
|
// reasoning verbatim: a record you can switch off is off on the run you
|
|
938
1114
|
// needed it for), and a key for the network probe.
|
|
939
1115
|
{ key: "extra_resume", def: "on", tier: "common", validate: vEnum("on", "off"), options: ["on", "off"], desc: "Whether a partial or crashed foreign dispatch is RESUMED rather than re-done. A worker cut off mid-write leaves a half-changed repository, and re-dispatching the SAME slice lands a fresh executor on a file that is already two-thirds written — it either discards work you paid for or improvises against a stale mental model. `on` reconciles the worktree against the journal baseline and composes a resume slice that says what is already there. Default `on`, because `off` is what is broken. INERT in /orc-quick, which asks which agent before every dispatch." },
|
|
1116
|
+
// --- v0.56.1 — the stall. ELEVEN keys became THIRTEEN, and both additions
|
|
1117
|
+
// come from the same observed failure: a foreign worker that goes quiet
|
|
1118
|
+
// mid-task. Deliberately NOT added: a key to nudge the child on its stdin
|
|
1119
|
+
// (`opencode run` is not an interactive session, so a keystroke nobody reads
|
|
1120
|
+
// is a fake fix), a per-profile stall budget (the number describes ORC's
|
|
1121
|
+
// patience, not a provider), and a key to disable the stall report.
|
|
1122
|
+
{ key: "extra_stall_s", def: 180, tier: "common", validate: vInt(0), options: [0, 60, 120, 180, 300, 600], desc: "Seconds a foreign worker may produce NOTHING before the dispatch is stopped as `stalled`. The clock is reset by observable progress — new bytes on the worker's stream, new bytes on stderr, or a declared file that changed on disk — so it never fires on a worker that is merely slow. This is what a wall clock cannot see: `extra_timeout_s` measures the whole dispatch, and an opencode that stops mid-task and waits for someone to type `continue` burns all 900 seconds looking like a timeout. A `stalled` dispatch is RETRYABLE, so `extra_resume` continues it from what is already on disk rather than starting over. 0 turns it off and the wall clock is the only stop again. Clamped below the wall clock, because a budget that can never fire is worse than none. Engine `cli` only — engine `api` already has a per-request inactivity timeout on its own socket." },
|
|
1123
|
+
{ key: "extra_fallback_agent", def: "band", tier: "common", validate: vFallbackAgent, options: ["band", "ask", "orc-executor-opus-5-med", "orc-executor-opus-5-low", "orc-executor-sonnet-4-6-high"], desc: "WHICH Claude agent picks up a task the foreign worker could not finish. `band` is the pre-v0.56.1 behaviour and stays the default: the exact agent the score table or the slot would have used, so a fallback changes WHO runs it and nothing else. `ask` STOPS and puts the choice to you with the failure and the position already on the table — right when a stall has just cost you fifteen minutes and you would rather pick than accept a default. Any installed agent name is accepted verbatim, for the case where you already know a stalled slice wants more (or less) than its band. It never changes the score, never widens `declared_files` and never moves `acceptance[]` — a fallback is not a re-plan." },
|
|
940
1124
|
{ key: "extra_resume_max", def: 2, tier: "advanced", validate: vInt(0), desc: "Resume attempts per task before the fallback procedure takes over. Bounded like `tdd_loop_max`: hitting the cap STOPS with an honest report naming the Claude agent, never a silent third loop. A resume never widens `declared_files`, never moves `acceptance[]` and never moves the score — it is a continuation, not a discount." },
|
|
941
1125
|
{ key: "opus5_only", def: false, tier: "common", validate: vEnum("true", "false"), options: ["true", "false"], desc: "EVERY dispatched role uses ONE model — Opus 5 — with EFFORT as the cost dial (executors: [0,40) low · [40,80) medium · [80,100] high; each fixed role its own pinned effort). Deep SWE-benchmark work on cost vs efficiency across Claude models finds a single Opus 5 agent with the effort ladder the most efficient setup. It FORCES: while on it outranks fable5_* and a hand-written rubric_bands_override. Needs an Opus 5 main session or EVERY dispatch silently downgrades. Excludes the Haiku trace writer and orc-diy (compile-owned)." },
|
|
942
1126
|
// --- v0.46.0 — the six new lanes ------------------------------------------
|
|
@@ -19711,6 +19895,202 @@ function maybeStorePendingKey(claudeDir, ledger, prof, pending, pendingPass, asJ
|
|
|
19711
19895
|
};
|
|
19712
19896
|
}
|
|
19713
19897
|
|
|
19898
|
+
// ── orc extra health — DOES THIS MODEL STALL? (v0.56.1) ────────────────────
|
|
19899
|
+
//
|
|
19900
|
+
// `orc extra ping --live` answers "did something come back". It cannot answer
|
|
19901
|
+
// the question a stall raises, which is "what was it DOING for those fifteen
|
|
19902
|
+
// minutes" — because a synchronous probe has no view of the child while it
|
|
19903
|
+
// runs, exactly the blindness runCliChild was built to remove.
|
|
19904
|
+
//
|
|
19905
|
+
// So health is the live probe run through THE SAME WATCHDOG A DISPATCH USES.
|
|
19906
|
+
// That matters: it is not a second idea of the path (the v0.53.3 rule — a green
|
|
19907
|
+
// badge must be earned by the path a wave actually runs), it is the dispatch's
|
|
19908
|
+
// own observer pointed at a one-line prompt. What it reports is a TIMELINE:
|
|
19909
|
+
// when the first byte arrived, the longest silence, and whether it ended by
|
|
19910
|
+
// answering, by stalling, or by running out of wall clock.
|
|
19911
|
+
//
|
|
19912
|
+
// Exit codes: 0 answered · 1 stalled or failed · 2 unknown profile.
|
|
19913
|
+
async function extraHealth(claudeDir, name) {
|
|
19914
|
+
const asJson = wantsJson();
|
|
19915
|
+
const ledger = readExtra(claudeDir) || emptyExtra();
|
|
19916
|
+
const cfg = resolvedConfig(claudeDir);
|
|
19917
|
+
const prof = extraProfile(ledger, name);
|
|
19918
|
+
const fail = (reason, error, code) => {
|
|
19919
|
+
if (asJson)
|
|
19920
|
+
console.log(JSON.stringify({ ok: false, command: "extra health", profile: name || null, reason, error }, null, 2));
|
|
19921
|
+
else console.error(`❌ ${error}`);
|
|
19922
|
+
process.exit(code);
|
|
19923
|
+
};
|
|
19924
|
+
if (!prof)
|
|
19925
|
+
return fail(
|
|
19926
|
+
"unknown-profile",
|
|
19927
|
+
name ? `no profile called "${name}". \`orc extra list\` has the names.` : "usage: orc extra health <profile> [--model <id>]",
|
|
19928
|
+
2
|
|
19929
|
+
);
|
|
19930
|
+
if (prof.engine !== "cli")
|
|
19931
|
+
return fail(
|
|
19932
|
+
"engine-unsupported",
|
|
19933
|
+
"`orc extra health` measures the STALL clock, and that clock only exists on engine `cli` — engine `" +
|
|
19934
|
+
prof.engine +
|
|
19935
|
+
"` has a per-request inactivity timeout on its own socket, which `orc extra ping " +
|
|
19936
|
+
prof.name +
|
|
19937
|
+
" --live` already exercises.",
|
|
19938
|
+
1
|
|
19939
|
+
);
|
|
19940
|
+
|
|
19941
|
+
const cat = readCatalog();
|
|
19942
|
+
const row = catalogRow(cat, prof.provider) || {};
|
|
19943
|
+
const bin = (prof.cli && prof.cli.bin) || row.cli_bin || null;
|
|
19944
|
+
const adapter = (bin && EXTRA_CLI_ADAPTERS[bin]) || null;
|
|
19945
|
+
const found = bin ? whichBin(bin) : null;
|
|
19946
|
+
if (!adapter || !found)
|
|
19947
|
+
return fail(
|
|
19948
|
+
"engine-unavailable",
|
|
19949
|
+
"`" + (bin || "(no binary configured)") + "` is not on PATH, or ORC ships no adapter for it.",
|
|
19950
|
+
1
|
|
19951
|
+
);
|
|
19952
|
+
|
|
19953
|
+
const modelArg = flag("--model");
|
|
19954
|
+
const model = typeof modelArg === "string" ? modelArg : prof.default_model || (prof.models_seen || [])[0] || null;
|
|
19955
|
+
if (!model)
|
|
19956
|
+
return fail(
|
|
19957
|
+
"no-model",
|
|
19958
|
+
`no model to probe. Name one: \`orc extra health ${prof.name} --model <id>\` (\`orc extra models ${prof.name}\` lists what the provider offers).`,
|
|
19959
|
+
1
|
|
19960
|
+
);
|
|
19961
|
+
|
|
19962
|
+
// The credential goes into the CHILD'S ENVIRONMENT and nowhere else, exactly
|
|
19963
|
+
// as a dispatch does it. A tool that holds its own key gets nothing and still
|
|
19964
|
+
// authenticates.
|
|
19965
|
+
const cred = extraCredentialValue(claudeDir, prof, { ambientKey: process.env.ORC_EXTRA_KEY || null });
|
|
19966
|
+
const env = {};
|
|
19967
|
+
if (cred.ok && cred.value) {
|
|
19968
|
+
const credRow = row.credential || {};
|
|
19969
|
+
if (credRow.env_var) env[credRow.env_var] = cred.value;
|
|
19970
|
+
const kn = (prof.credential || {}).key_name;
|
|
19971
|
+
if (kn) env[kn] = cred.value;
|
|
19972
|
+
}
|
|
19973
|
+
|
|
19974
|
+
const timeouts = extraTimeouts(cfg);
|
|
19975
|
+
let scratch = null;
|
|
19976
|
+
try {
|
|
19977
|
+
scratch = fs.mkdtempSync(path.join(os.tmpdir(), "orc-extra-health-"));
|
|
19978
|
+
} catch (e) {
|
|
19979
|
+
return fail("scratch-failed", String((e && e.message) || e), 1);
|
|
19980
|
+
}
|
|
19981
|
+
let version = null;
|
|
19982
|
+
if (row.version_cmd) {
|
|
19983
|
+
const v = runToolCmd(found, row.version_cmd, env);
|
|
19984
|
+
version = parseVersion(v.stdout + "\n" + v.stderr);
|
|
19985
|
+
}
|
|
19986
|
+
const argv = adapter.probeArgv({ model, dir: scratch, prompt: EXTRA_LIVE_PROMPT, version });
|
|
19987
|
+
const started = Date.now();
|
|
19988
|
+
const r = await runCliChild(
|
|
19989
|
+
found,
|
|
19990
|
+
argv,
|
|
19991
|
+
{ cwd: scratch, env: Object.assign({}, process.env, env), windowsHide: true, stdio: ["ignore", "pipe", "pipe"] },
|
|
19992
|
+
{ started, wall_ms: timeouts.wall_ms, stall_ms: timeouts.stall_ms, progressFile: null, root: scratch, declared: [] }
|
|
19993
|
+
);
|
|
19994
|
+
const total = Date.now() - started;
|
|
19995
|
+
let parsed = null;
|
|
19996
|
+
try {
|
|
19997
|
+
parsed = adapter.parse({ stdout: r.stdout || "", stderr: r.stderr || "", lastMessage: null });
|
|
19998
|
+
} catch (_) {}
|
|
19999
|
+
try {
|
|
20000
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
20001
|
+
} catch (_) {}
|
|
20002
|
+
|
|
20003
|
+
const verdict =
|
|
20004
|
+
r.stopped_by === "stall"
|
|
20005
|
+
? "stalled"
|
|
20006
|
+
: r.stopped_by === "wall"
|
|
20007
|
+
? "timeout"
|
|
20008
|
+
: r.error
|
|
20009
|
+
? "spawn-failed"
|
|
20010
|
+
: r.status === 0
|
|
20011
|
+
? "answered"
|
|
20012
|
+
: "failed";
|
|
20013
|
+
const text = (parsed && parsed.text) || String(r.stdout || "").trim() || null;
|
|
20014
|
+
const out = {
|
|
20015
|
+
ok: verdict === "answered",
|
|
20016
|
+
command: "extra health",
|
|
20017
|
+
profile: prof.name,
|
|
20018
|
+
provider: prof.provider,
|
|
20019
|
+
engine: "cli",
|
|
20020
|
+
adapter: bin,
|
|
20021
|
+
model,
|
|
20022
|
+
verdict,
|
|
20023
|
+
exit_code: r.status,
|
|
20024
|
+
credential: { source: cred.source || null, ok: !!cred.ok },
|
|
20025
|
+
timeline: {
|
|
20026
|
+
total_ms: total,
|
|
20027
|
+
first_byte_ms: r.first_byte_ms,
|
|
20028
|
+
last_progress_ms: r.last_progress_ms,
|
|
20029
|
+
longest_gap_ms: r.longest_gap_ms,
|
|
20030
|
+
quiet_for_ms: r.stall_ms,
|
|
20031
|
+
stall_budget_ms: timeouts.stall_ms,
|
|
20032
|
+
wall_budget_ms: timeouts.wall_ms,
|
|
20033
|
+
stall_budget_clamped: !!timeouts.stall_clamped,
|
|
20034
|
+
},
|
|
20035
|
+
// WHAT IT WAS DOING — the bytes, capped, never interpreted. On engine `cli`
|
|
20036
|
+
// ORC did not compose the request and does not parse the tool's turns, so
|
|
20037
|
+
// this is the log and nothing more (`journal_fidelity: streamed-opaque`).
|
|
20038
|
+
log_excerpt: text ? String(text).slice(0, EXTRA_LIVE_EXCERPT_MAX) : null,
|
|
20039
|
+
log_truncated: !!(text && String(text).length > EXTRA_LIVE_EXCERPT_MAX),
|
|
20040
|
+
stderr_excerpt: String(r.stderr || "").trim().slice(0, EXTRA_LIVE_EXCERPT_MAX) || null,
|
|
20041
|
+
events: (parsed && parsed.events) || 0,
|
|
20042
|
+
tokens: (parsed && parsed.usage) || null,
|
|
20043
|
+
foreign_reply_note: EXTRA_FOREIGN_REPLY_NOTE,
|
|
20044
|
+
cost_note: EXTRA_LIVE_COST_CLI,
|
|
20045
|
+
};
|
|
20046
|
+
if (asJson) {
|
|
20047
|
+
console.log(JSON.stringify(out, null, 2));
|
|
20048
|
+
process.exit(out.ok ? 0 : 1);
|
|
20049
|
+
}
|
|
20050
|
+
const sec = (ms) => (ms === null || ms === undefined ? "—" : `${(ms / 1000).toFixed(1)}s`);
|
|
20051
|
+
console.log("");
|
|
20052
|
+
console.log(ui.color.bold(`ORC · extra — health · ${prof.name} · ${model}`));
|
|
20053
|
+
console.log("─".repeat(40));
|
|
20054
|
+
console.log("");
|
|
20055
|
+
const mark = out.ok ? ui.color.green("✔") : ui.color.red("✖");
|
|
20056
|
+
console.log(` ${mark} ${verdict}${out.exit_code === null ? "" : ui.color.gray(` exit ${out.exit_code}`)}`);
|
|
20057
|
+
console.log("");
|
|
20058
|
+
console.log(` first byte ${sec(out.timeline.first_byte_ms)}`);
|
|
20059
|
+
console.log(` last progress ${sec(out.timeline.last_progress_ms)}`);
|
|
20060
|
+
console.log(` longest quiet gap ${sec(out.timeline.longest_gap_ms)}`);
|
|
20061
|
+
console.log(` total ${sec(out.timeline.total_ms)}`);
|
|
20062
|
+
console.log(
|
|
20063
|
+
` stall budget ${
|
|
20064
|
+
out.timeline.stall_budget_ms ? sec(out.timeline.stall_budget_ms) : ui.color.gray("off (extra_stall_s = 0)")
|
|
20065
|
+
}` + (out.timeline.stall_budget_clamped ? ui.color.yellow(" ← clamped under the wall clock") : "")
|
|
20066
|
+
);
|
|
20067
|
+
if (verdict === "stalled") {
|
|
20068
|
+
console.log("");
|
|
20069
|
+
console.log(
|
|
20070
|
+
ui.color.yellow(
|
|
20071
|
+
` This model went quiet for ${sec(out.timeline.quiet_for_ms)} and was still running. In a wave\n` +
|
|
20072
|
+
" that is a `stalled` dispatch: retryable, so `extra_resume` continues it from what\n" +
|
|
20073
|
+
" is on disk rather than starting over."
|
|
20074
|
+
)
|
|
20075
|
+
);
|
|
20076
|
+
}
|
|
20077
|
+
if (out.log_excerpt) {
|
|
20078
|
+
console.log("");
|
|
20079
|
+
console.log(ui.color.gray(" what it said"));
|
|
20080
|
+
for (const line of out.log_excerpt.split(/\r?\n/).slice(0, 12)) console.log(" " + line);
|
|
20081
|
+
if (out.log_truncated) console.log(ui.color.gray(" …"));
|
|
20082
|
+
console.log("");
|
|
20083
|
+
console.log(ui.color.gray(" " + EXTRA_FOREIGN_REPLY_NOTE));
|
|
20084
|
+
} else if (out.stderr_excerpt) {
|
|
20085
|
+
console.log("");
|
|
20086
|
+
console.log(ui.color.gray(" stderr"));
|
|
20087
|
+
for (const line of out.stderr_excerpt.split(/\r?\n/).slice(0, 8)) console.log(" " + line);
|
|
20088
|
+
}
|
|
20089
|
+
console.log("");
|
|
20090
|
+
console.log(ui.color.gray(" " + EXTRA_LIVE_COST_CLI));
|
|
20091
|
+
process.exit(out.ok ? 0 : 1);
|
|
20092
|
+
}
|
|
20093
|
+
|
|
19714
20094
|
function extraPingRender(res) {
|
|
19715
20095
|
if (res.ok) {
|
|
19716
20096
|
console.log(
|
|
@@ -21738,6 +22118,180 @@ const EXTRA_TOOLS_ALLOWED = new Set([
|
|
|
21738
22118
|
//
|
|
21739
22119
|
// Engine B needs exactly the same protection (`codex` is an npm shim too), which
|
|
21740
22120
|
// is why this is named for what it does rather than for its first caller.
|
|
22121
|
+
// ── The stall watchdog (v0.56.1) ───────────────────────────────────────────
|
|
22122
|
+
//
|
|
22123
|
+
// `spawnSync` cannot have one. It blocks the event loop for the whole dispatch,
|
|
22124
|
+
// so nothing in this process can observe the child while it runs — which is why
|
|
22125
|
+
// a stalled worker could only ever be discovered by the wall clock, fifteen
|
|
22126
|
+
// minutes later, wearing the wrong name (`timeout`).
|
|
22127
|
+
//
|
|
22128
|
+
// So engine `cli` spawns ASYNCHRONOUSLY and something watches. What it watches
|
|
22129
|
+
// is deliberately not "is the process alive" — a stalled opencode is extremely
|
|
22130
|
+
// alive. It watches for OBSERVABLE PROGRESS, in the three places progress can
|
|
22131
|
+
// show up:
|
|
22132
|
+
//
|
|
22133
|
+
// 1. the child's stdout, which is redirected onto the journal's progress file
|
|
22134
|
+
// (so the measurement is a `stat`, not a buffer this process holds),
|
|
22135
|
+
// 2. the child's stderr, which stays a pipe and is counted as it arrives,
|
|
22136
|
+
// 3. the DECLARED FILES on disk — a worker can think for four minutes and
|
|
22137
|
+
// write nothing, but a worker that just wrote a file is working, whatever
|
|
22138
|
+
// its stream is doing.
|
|
22139
|
+
//
|
|
22140
|
+
// Any one of the three resets the clock. All three quiet for the whole stall
|
|
22141
|
+
// budget is the finding.
|
|
22142
|
+
function spawnCmdParts(bin, argv) {
|
|
22143
|
+
const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
|
|
22144
|
+
if (!needsShell) return { cmd: bin, args: argv, shell: false };
|
|
22145
|
+
const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
|
22146
|
+
return { cmd: q(bin) + " " + argv.map(q).join(" "), args: [], shell: true };
|
|
22147
|
+
}
|
|
22148
|
+
|
|
22149
|
+
// A .cmd shim on Windows is `cmd.exe` with the real tool as a GRANDCHILD, and
|
|
22150
|
+
// killing cmd.exe leaves the tool running against the same repository the next
|
|
22151
|
+
// attempt is about to resume into. `taskkill /T` is the only thing that takes
|
|
22152
|
+
// the tree. On POSIX the child is spawned into its own process group and the
|
|
22153
|
+
// group is signalled.
|
|
22154
|
+
function killProcessTree(child) {
|
|
22155
|
+
if (!child || child.pid === undefined || child.exitCode !== null) return;
|
|
22156
|
+
if (process.platform === "win32") {
|
|
22157
|
+
try {
|
|
22158
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
22159
|
+
return;
|
|
22160
|
+
} catch (_) {}
|
|
22161
|
+
}
|
|
22162
|
+
try {
|
|
22163
|
+
process.kill(-child.pid, "SIGTERM");
|
|
22164
|
+
} catch (_) {
|
|
22165
|
+
try {
|
|
22166
|
+
child.kill("SIGTERM");
|
|
22167
|
+
} catch (_) {}
|
|
22168
|
+
}
|
|
22169
|
+
}
|
|
22170
|
+
|
|
22171
|
+
// The size of every declared file, plus its mtime. Cheap enough to run every
|
|
22172
|
+
// few seconds, and it is the ONE progress signal that does not depend on the
|
|
22173
|
+
// worker choosing to say anything.
|
|
22174
|
+
function declaredFilesFingerprint(root, declared) {
|
|
22175
|
+
let acc = "";
|
|
22176
|
+
for (const rel of declared || []) {
|
|
22177
|
+
try {
|
|
22178
|
+
const st = fs.statSync(path.resolve(root, rel));
|
|
22179
|
+
acc += `${rel}:${st.size}:${st.mtimeMs};`;
|
|
22180
|
+
} catch (_) {
|
|
22181
|
+
acc += `${rel}:-;`;
|
|
22182
|
+
}
|
|
22183
|
+
}
|
|
22184
|
+
return acc;
|
|
22185
|
+
}
|
|
22186
|
+
|
|
22187
|
+
const EXTRA_STALL_POLL_MS = 5000;
|
|
22188
|
+
|
|
22189
|
+
function runCliChild(bin, argv, opts, watch) {
|
|
22190
|
+
return new Promise((resolve) => {
|
|
22191
|
+
const parts = spawnCmdParts(bin, argv);
|
|
22192
|
+
const spawnOpts = Object.assign({}, opts, { shell: parts.shell });
|
|
22193
|
+
if (!parts.shell && process.platform !== "win32") spawnOpts.detached = true;
|
|
22194
|
+
let child;
|
|
22195
|
+
try {
|
|
22196
|
+
child = require("child_process").spawn(parts.cmd, parts.args, spawnOpts);
|
|
22197
|
+
} catch (e) {
|
|
22198
|
+
return resolve({ error: e, status: null, stderr: "", stopped_by: null, stall_ms: null, last_progress_ms: null });
|
|
22199
|
+
}
|
|
22200
|
+
|
|
22201
|
+
let stderr = "";
|
|
22202
|
+
let stderrBytes = 0;
|
|
22203
|
+
if (child.stderr)
|
|
22204
|
+
child.stderr.on("data", (c) => {
|
|
22205
|
+
stderrBytes += c.length;
|
|
22206
|
+
if (stderr.length < 512 * 1024) stderr += c.toString("utf8");
|
|
22207
|
+
});
|
|
22208
|
+
|
|
22209
|
+
// When there is no journal to redirect stdout onto, the bytes are collected
|
|
22210
|
+
// HERE instead — never dropped. A journal is best effort by construction
|
|
22211
|
+
// (it can be null), and a dispatch that silently produced no readable output
|
|
22212
|
+
// because ORC could not write a log file would be a far worse failure than
|
|
22213
|
+
// the missing log.
|
|
22214
|
+
let stdout = "";
|
|
22215
|
+
let stdoutBytes = 0;
|
|
22216
|
+
if (child.stdout)
|
|
22217
|
+
child.stdout.on("data", (c) => {
|
|
22218
|
+
stdoutBytes += c.length;
|
|
22219
|
+
if (stdout.length < EXTRA_MAX_OUTPUT_BYTES) stdout += c.toString("utf8");
|
|
22220
|
+
});
|
|
22221
|
+
|
|
22222
|
+
let stoppedBy = null;
|
|
22223
|
+
let lastProgress = Date.now();
|
|
22224
|
+
let longestGap = 0;
|
|
22225
|
+
let firstByteAt = null;
|
|
22226
|
+
let seen = { out: -1, err: -1, files: null };
|
|
22227
|
+
|
|
22228
|
+
// `true` on the very first call: the baseline is what everything after it is
|
|
22229
|
+
// compared AGAINST, so counting it as movement would report a first byte at
|
|
22230
|
+
// 0ms on every dispatch — a timeline that always says the worker started
|
|
22231
|
+
// instantly is a timeline nobody can read a stall out of.
|
|
22232
|
+
const sample = (baseline) => {
|
|
22233
|
+
let outSize = stdoutBytes;
|
|
22234
|
+
if (watch.progressFile) {
|
|
22235
|
+
try {
|
|
22236
|
+
outSize = fs.statSync(watch.progressFile).size;
|
|
22237
|
+
} catch (_) {}
|
|
22238
|
+
}
|
|
22239
|
+
const files = declaredFilesFingerprint(watch.root, watch.declared);
|
|
22240
|
+
const moved =
|
|
22241
|
+
outSize !== seen.out || stderrBytes !== seen.err || (seen.files !== null && files !== seen.files);
|
|
22242
|
+
seen = { out: outSize, err: stderrBytes, files };
|
|
22243
|
+
if (moved && !baseline) {
|
|
22244
|
+
const now = Date.now();
|
|
22245
|
+
longestGap = Math.max(longestGap, now - lastProgress);
|
|
22246
|
+
if (firstByteAt === null) firstByteAt = now;
|
|
22247
|
+
lastProgress = now;
|
|
22248
|
+
}
|
|
22249
|
+
return moved;
|
|
22250
|
+
};
|
|
22251
|
+
sample(true); // establish the baseline WITHOUT counting it as movement
|
|
22252
|
+
|
|
22253
|
+
const wall = setTimeout(() => {
|
|
22254
|
+
stoppedBy = "wall";
|
|
22255
|
+
killProcessTree(child);
|
|
22256
|
+
}, watch.wall_ms);
|
|
22257
|
+
|
|
22258
|
+
let poll = null;
|
|
22259
|
+
if (watch.stall_ms > 0) {
|
|
22260
|
+
poll = setInterval(() => {
|
|
22261
|
+
sample();
|
|
22262
|
+
if (Date.now() - lastProgress >= watch.stall_ms) {
|
|
22263
|
+
stoppedBy = "stall";
|
|
22264
|
+
clearInterval(poll);
|
|
22265
|
+
poll = null;
|
|
22266
|
+
killProcessTree(child);
|
|
22267
|
+
}
|
|
22268
|
+
}, Math.min(EXTRA_STALL_POLL_MS, Math.max(1000, Math.floor(watch.stall_ms / 4))));
|
|
22269
|
+
}
|
|
22270
|
+
|
|
22271
|
+
const done = (status, err) => {
|
|
22272
|
+
clearTimeout(wall);
|
|
22273
|
+
if (poll) clearInterval(poll);
|
|
22274
|
+
sample();
|
|
22275
|
+
resolve({
|
|
22276
|
+
error: err || null,
|
|
22277
|
+
status,
|
|
22278
|
+
stdout,
|
|
22279
|
+
stderr,
|
|
22280
|
+
stopped_by: stoppedBy,
|
|
22281
|
+
// The timeline, for `orc extra health` and for the failure report. A
|
|
22282
|
+
// stall report that cannot say how long the worker was quiet is a
|
|
22283
|
+
// report nobody can tune a budget from.
|
|
22284
|
+
stall_ms: Date.now() - lastProgress,
|
|
22285
|
+
longest_gap_ms: longestGap,
|
|
22286
|
+
first_byte_ms: firstByteAt === null ? null : firstByteAt - watch.started,
|
|
22287
|
+
last_progress_ms: lastProgress - watch.started,
|
|
22288
|
+
});
|
|
22289
|
+
};
|
|
22290
|
+
child.on("error", (e) => done(null, e));
|
|
22291
|
+
child.on("close", (code) => done(code === null ? null : code, null));
|
|
22292
|
+
});
|
|
22293
|
+
}
|
|
22294
|
+
|
|
21741
22295
|
function spawnCmdSafe(bin, argv, opts) {
|
|
21742
22296
|
const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
|
|
21743
22297
|
if (!needsShell) return spawnSync(bin, argv, opts);
|
|
@@ -21755,7 +22309,43 @@ function extraTimeouts(cfg) {
|
|
|
21755
22309
|
const wall = Math.max(30, Number(cfg.extra_timeout_s) || 900) * 1000;
|
|
21756
22310
|
const api = Math.min(wall, Math.max(60000, wall - 60000));
|
|
21757
22311
|
const idle = Math.max(10000, Math.min(300000, api - 30000, 1800000));
|
|
21758
|
-
|
|
22312
|
+
// v0.56.1 — THE FOURTH TIMEOUT, and the only one that measures the WORKER
|
|
22313
|
+
// rather than a socket. A wall clock cannot tell a worker that is thinking
|
|
22314
|
+
// hard from one that stopped: both look like 900 seconds of nothing. The
|
|
22315
|
+
// stall clock is reset by OBSERVABLE PROGRESS — new bytes on the worker's own
|
|
22316
|
+
// stream, new bytes on its stderr, or a declared file that changed on disk —
|
|
22317
|
+
// so it fires only when nothing at all is happening.
|
|
22318
|
+
//
|
|
22319
|
+
// Ordered with the rest, once: stall < idle < api < wall. A stall budget at
|
|
22320
|
+
// or past the wall clock can never fire, so it is CLAMPED rather than
|
|
22321
|
+
// honoured, and the clamp is reported by `orc extra health`.
|
|
22322
|
+
const raw = cfg.extra_stall_s === undefined ? 180 : Number(cfg.extra_stall_s);
|
|
22323
|
+
const asked = !Number.isFinite(raw) || raw <= 0 ? 0 : raw * 1000;
|
|
22324
|
+
// STRICTLY under the wall clock, and never below the 30s floor. Those two
|
|
22325
|
+
// rules can disagree — a 30-second wall clock leaves no room for either — and
|
|
22326
|
+
// when they do the stall clock is simply OFF. Two timers that fire at the same
|
|
22327
|
+
// instant would report whichever won the race, which is exactly the "three
|
|
22328
|
+
// timeouts disagreeing about which one fires first" bug this function exists
|
|
22329
|
+
// to prevent; the wall clock is the one that must win a tie, because it is
|
|
22330
|
+
// the budget the user set.
|
|
22331
|
+
const fitted = Math.min(asked, wall - 15000);
|
|
22332
|
+
const stall = asked === 0 || fitted < 30000 ? 0 : fitted;
|
|
22333
|
+
return {
|
|
22334
|
+
wall_ms: wall,
|
|
22335
|
+
api_ms: api,
|
|
22336
|
+
idle_ms: idle,
|
|
22337
|
+
stall_ms: stall,
|
|
22338
|
+
stall_clamped: asked > 0 && stall !== asked,
|
|
22339
|
+
// WHY it is off, when it is off and the user asked for one. A budget that
|
|
22340
|
+
// silently does nothing is the failure mode; a budget that says "your wall
|
|
22341
|
+
// clock is too short for me" is a thing somebody can act on.
|
|
22342
|
+
stall_off_reason:
|
|
22343
|
+
asked > 0 && stall === 0
|
|
22344
|
+
? `extra_stall_s (${Math.round(asked / 1000)}s) cannot fit under extra_timeout_s (${Math.round(
|
|
22345
|
+
wall / 1000
|
|
22346
|
+
)}s) with the 30s floor, so the wall clock is the only stop.`
|
|
22347
|
+
: null,
|
|
22348
|
+
};
|
|
21759
22349
|
}
|
|
21760
22350
|
|
|
21761
22351
|
// ── Managed settings (F4, last row) ────────────────────────────────────────
|
|
@@ -21919,6 +22509,20 @@ const EXTRA_FAILURES = {
|
|
|
21919
22509
|
"connection-lost-local": { retry: true, label: "the connection dropped and this machine could not reach the network either" },
|
|
21920
22510
|
"redirect-refused": { retry: false, label: "the endpoint redirected, and a credential must never follow one" },
|
|
21921
22511
|
"response-truncated": { retry: true, label: "the reply exceeded the response ceiling" },
|
|
22512
|
+
// v0.56.1 — THE ROW FOR A WORKER THAT IS STILL ALIVE AND DOING NOTHING.
|
|
22513
|
+
//
|
|
22514
|
+
// `timeout` says the whole dispatch budget ran out, which is a statement
|
|
22515
|
+
// about ORC's patience. `stalled` says the worker went quiet: no new bytes on
|
|
22516
|
+
// its stream, no new bytes on stderr, and no declared file changed, for the
|
|
22517
|
+
// whole stall budget. The process was up the entire time — an opencode
|
|
22518
|
+
// session that stops mid-task and waits for a human to type "continue" looks
|
|
22519
|
+
// exactly like this, and reporting it as `timeout` hid that it was a
|
|
22520
|
+
// RESUMABLE position rather than a budget somebody should raise.
|
|
22521
|
+
//
|
|
22522
|
+
// Retryable, and that is the point: `extra_resume` turns it into a
|
|
22523
|
+
// continuation slice carrying what is already on disk, which is ORC's own
|
|
22524
|
+
// spelling of typing "continue".
|
|
22525
|
+
stalled: { retry: true, label: "the worker went quiet and stopped changing anything" },
|
|
21922
22526
|
unknown: { retry: false, label: "the worker failed and said nothing ORC can classify" },
|
|
21923
22527
|
};
|
|
21924
22528
|
const classifyFailure = (k) => EXTRA_FAILURES[k] || EXTRA_FAILURES.unknown;
|
|
@@ -23105,6 +23709,27 @@ function extraJournalCmd(claudeDir, sub, arg) {
|
|
|
23105
23709
|
// same profile" and then wait out `extra_resume_max` × a 401.
|
|
23106
23710
|
const EXTRA_RESUME_TARGETS = ["extra", "claude", "hold", "off"];
|
|
23107
23711
|
|
|
23712
|
+
// ONE reader of "which Claude agent takes this over". A resume that goes to
|
|
23713
|
+
// Claude and a fallback that goes to Claude must never disagree about the
|
|
23714
|
+
// answer, and before v0.56.1 there was only one place it could come from. Now
|
|
23715
|
+
// `extra_fallback_agent` can pin one or defer to the user, so the resolved
|
|
23716
|
+
// choice wins where it made one and `fallback_to` is still the floor. `null`
|
|
23717
|
+
// under `ask` is CORRECT and not a gap: nothing has been chosen yet.
|
|
23718
|
+
function extraFallbackAgentOf(result) {
|
|
23719
|
+
const f = result && result.fallback;
|
|
23720
|
+
// `ask` deliberately resolves to NULL — nothing has been chosen yet, and a
|
|
23721
|
+
// resume report that named an agent would answer the question the setting
|
|
23722
|
+
// exists to ask. The renderer prints the menu instead.
|
|
23723
|
+
if (f && f.mode === "ask") return null;
|
|
23724
|
+
// ONLY a PINNED setting overrides. Under `band` the two fields agree by
|
|
23725
|
+
// construction (both are `res.claude`), and `fallback_to` keeps its name, its
|
|
23726
|
+
// position and its meaning as the promise at the payload says — so it stays
|
|
23727
|
+
// the authority for the default, and this function is the exception, not a
|
|
23728
|
+
// replacement.
|
|
23729
|
+
if (f && f.mode === "pinned" && f.agent) return f.agent;
|
|
23730
|
+
return (result && result.fallback_to && result.fallback_to.agent) || (f && f.agent) || null;
|
|
23731
|
+
}
|
|
23732
|
+
|
|
23108
23733
|
// ONE idea of what "on" means. Two readers of the same key that disagree about
|
|
23109
23734
|
// its default is the drift this repo lints for everywhere else.
|
|
23110
23735
|
const extraResumeOn = (cfg) => String((cfg && cfg.extra_resume) === undefined ? "on" : cfg.extra_resume) !== "off";
|
|
@@ -23144,7 +23769,7 @@ function extraResumeTarget(v, result, cfg) {
|
|
|
23144
23769
|
return {
|
|
23145
23770
|
kind: "claude",
|
|
23146
23771
|
profile: null,
|
|
23147
|
-
agent: (result
|
|
23772
|
+
agent: extraFallbackAgentOf(result),
|
|
23148
23773
|
why: `\`extra_resume_max\` is ${max} and this task has already been resumed ${resumesSoFar} time(s). The cap STOPS with an honest report rather than looping a third time — bounded like \`tdd_loop_max\`.`,
|
|
23149
23774
|
capped: true,
|
|
23150
23775
|
resumes_so_far: resumesSoFar,
|
|
@@ -23165,7 +23790,7 @@ function extraResumeTarget(v, result, cfg) {
|
|
|
23165
23790
|
return {
|
|
23166
23791
|
kind: "claude",
|
|
23167
23792
|
profile: null,
|
|
23168
|
-
agent: (result
|
|
23793
|
+
agent: extraFallbackAgentOf(result),
|
|
23169
23794
|
// THE CASE THAT FIXES GAP 1. A Claude executor receiving a resume slice gets
|
|
23170
23795
|
// the same preamble, the same `preexisting[]` table and the same instruction
|
|
23171
23796
|
// not to rewrite finished work. The Claude fallback stops being a
|
|
@@ -23176,6 +23801,78 @@ function extraResumeTarget(v, result, cfg) {
|
|
|
23176
23801
|
};
|
|
23177
23802
|
}
|
|
23178
23803
|
|
|
23804
|
+
// ── WHICH Claude agent picks up a failed foreign task (v0.56.1) ────────────
|
|
23805
|
+
//
|
|
23806
|
+
// `fallback_to` has always carried the band's (or the slot's) own Claude agent,
|
|
23807
|
+
// which is the right default and the only one ORC can compute: a fallback that
|
|
23808
|
+
// changes tier is a re-plan nobody asked for.
|
|
23809
|
+
//
|
|
23810
|
+
// What it could not do is let a human choose. A stall costs real minutes before
|
|
23811
|
+
// anybody hears about it, and by the time the wave stops the user often knows
|
|
23812
|
+
// something ORC does not — that this slice wants more thinking than its band
|
|
23813
|
+
// bought, or less. So `extra_fallback_agent` adds two words to the default:
|
|
23814
|
+
// `ask` STOPS with the menu, and a bare agent name pins one.
|
|
23815
|
+
//
|
|
23816
|
+
// The menu is COMPUTED, never a second idea of the ladder: the band's own agent
|
|
23817
|
+
// always leads it (it is what happens if the user just presses enter), and the
|
|
23818
|
+
// three named alternates are the ones a person actually reaches for. "Any other
|
|
23819
|
+
// installed agent" is the honest last row — the roster is generated, and a
|
|
23820
|
+
// closed list here would go stale the next time a band moves.
|
|
23821
|
+
const EXTRA_FALLBACK_ALTERNATES = [
|
|
23822
|
+
{ agent: "orc-executor-opus-5-med", why: "more thinking than most bands buy, without the top-tier cost." },
|
|
23823
|
+
{ agent: "orc-executor-opus-5-low", why: "the same model on the cheapest rung — right when the slice turned out to be mechanical." },
|
|
23824
|
+
{ agent: "orc-executor-sonnet-4-6-high", why: "a different model entirely, for a slice where Opus is not the constraint." },
|
|
23825
|
+
];
|
|
23826
|
+
|
|
23827
|
+
function extraFallbackChoice(cfg, claude) {
|
|
23828
|
+
const bandAgent = (claude && claude.agent) || null;
|
|
23829
|
+
const setting = String((cfg && cfg.extra_fallback_agent) || "band").trim() || "band";
|
|
23830
|
+
const options = [];
|
|
23831
|
+
if (bandAgent)
|
|
23832
|
+
options.push({
|
|
23833
|
+
agent: bandAgent,
|
|
23834
|
+
label: "the agent this task would have had",
|
|
23835
|
+
why: "a fallback changes WHO runs the task and nothing else — the score, the declared files and the acceptance criteria are untouched.",
|
|
23836
|
+
is_band: true,
|
|
23837
|
+
});
|
|
23838
|
+
for (const alt of EXTRA_FALLBACK_ALTERNATES)
|
|
23839
|
+
if (alt.agent !== bandAgent) options.push({ agent: alt.agent, label: alt.agent, why: alt.why, is_band: false });
|
|
23840
|
+
|
|
23841
|
+
if (setting === "ask")
|
|
23842
|
+
return {
|
|
23843
|
+
mode: "ask",
|
|
23844
|
+
agent: null,
|
|
23845
|
+
band_agent: bandAgent,
|
|
23846
|
+
options,
|
|
23847
|
+
free_text: "any installed ORC agent name is also accepted",
|
|
23848
|
+
// The lane STOPS here. It is a config the user set on purpose, so a lane
|
|
23849
|
+
// that quietly picked the default for them would be answering the one
|
|
23850
|
+
// question the setting exists to ask.
|
|
23851
|
+
note: "`extra_fallback_agent` is `ask` — put these to the user and dispatch what they pick. Do not choose one for them.",
|
|
23852
|
+
};
|
|
23853
|
+
if (setting === "band")
|
|
23854
|
+
return {
|
|
23855
|
+
mode: "band",
|
|
23856
|
+
agent: bandAgent,
|
|
23857
|
+
band_agent: bandAgent,
|
|
23858
|
+
options,
|
|
23859
|
+
free_text: null,
|
|
23860
|
+
note: bandAgent
|
|
23861
|
+
? `dispatch \`${bandAgent}\` — the agent this band or slot already resolves to.`
|
|
23862
|
+
: "no Claude agent could be resolved for this task, so there is nothing to fall back to. Report that rather than guessing one.",
|
|
23863
|
+
};
|
|
23864
|
+
return {
|
|
23865
|
+
mode: "pinned",
|
|
23866
|
+
agent: setting,
|
|
23867
|
+
band_agent: bandAgent,
|
|
23868
|
+
options,
|
|
23869
|
+
free_text: null,
|
|
23870
|
+
note:
|
|
23871
|
+
`\`extra_fallback_agent\` pins every fallback to \`${setting}\`` +
|
|
23872
|
+
(bandAgent && bandAgent !== setting ? `, overriding this task's own \`${bandAgent}\`.` : "."),
|
|
23873
|
+
};
|
|
23874
|
+
}
|
|
23875
|
+
|
|
23179
23876
|
// ONE WORDING, composed here. Renderers print it; nobody writes a second one.
|
|
23180
23877
|
function extraResumePreamble(v, target) {
|
|
23181
23878
|
const line = (f) => {
|
|
@@ -25334,7 +26031,7 @@ const EXTRA_CLI_ADAPTERS = {
|
|
|
25334
26031
|
// property that kept the bridge, the slot accounting, the render and every
|
|
25335
26032
|
// downstream gate untouched across three engines, and it is worth more than any
|
|
25336
26033
|
// one adapter.
|
|
25337
|
-
function runCliEngine(ctx) {
|
|
26034
|
+
async function runCliEngine(ctx) {
|
|
25338
26035
|
const { prof, route, key, cfg, slice, cwd, root, workDir } = ctx;
|
|
25339
26036
|
const started = Date.now();
|
|
25340
26037
|
const timeouts = extraTimeouts(cfg);
|
|
@@ -25469,14 +26166,20 @@ function runCliEngine(ctx) {
|
|
|
25469
26166
|
const spawnOpts = {
|
|
25470
26167
|
cwd,
|
|
25471
26168
|
env: Object.assign({}, process.env, adapter.env(a)),
|
|
25472
|
-
encoding: "utf8",
|
|
25473
|
-
timeout: timeouts.wall_ms,
|
|
25474
|
-
killSignal: "SIGTERM",
|
|
25475
|
-
maxBuffer: EXTRA_MAX_OUTPUT_BYTES,
|
|
25476
26169
|
windowsHide: true,
|
|
25477
26170
|
};
|
|
25478
|
-
|
|
25479
|
-
|
|
26171
|
+
// stdout to the fd when there is one; a pipe we never read otherwise, so a
|
|
26172
|
+
// journal-less dispatch cannot fill the OS buffer and deadlock the child.
|
|
26173
|
+
spawnOpts.stdio = ["ignore", fd !== null ? fd : "pipe", "pipe"];
|
|
26174
|
+
// v0.56.1 — ASYNCHRONOUS, so something can watch. See runCliChild.
|
|
26175
|
+
const r = await runCliChild(bin, argv, spawnOpts, {
|
|
26176
|
+
started,
|
|
26177
|
+
wall_ms: timeouts.wall_ms,
|
|
26178
|
+
stall_ms: timeouts.stall_ms,
|
|
26179
|
+
progressFile: (ctx.journal && ctx.journal.progress) || null,
|
|
26180
|
+
root,
|
|
26181
|
+
declared,
|
|
26182
|
+
});
|
|
25480
26183
|
// READ BACK FIRST, before any early return. A timeout is exactly the case
|
|
25481
26184
|
// where the bytes matter most, and the old code discarded them.
|
|
25482
26185
|
let captured = null;
|
|
@@ -25493,23 +26196,50 @@ function runCliEngine(ctx) {
|
|
|
25493
26196
|
}
|
|
25494
26197
|
const childOut = captured === null ? r.stdout || "" : captured;
|
|
25495
26198
|
|
|
25496
|
-
|
|
25497
|
-
|
|
25498
|
-
|
|
25499
|
-
|
|
25500
|
-
|
|
25501
|
-
|
|
25502
|
-
|
|
25503
|
-
|
|
25504
|
-
|
|
25505
|
-
|
|
26199
|
+
// WHERE THE BYTES ARE, on either stop. The child's output up to the kill is
|
|
26200
|
+
// on disk, and a path to a file that exists is the difference between a bare
|
|
26201
|
+
// verdict and a position somebody can look at.
|
|
26202
|
+
const stopEvidence = {
|
|
26203
|
+
adapter: cli.bin,
|
|
26204
|
+
argv: argvUsed,
|
|
26205
|
+
// NAMED on a stop, not only on a completion. The trace line falls back to
|
|
26206
|
+
// `?` without it, and `orc extra stats` dedupes on the fields that line
|
|
26207
|
+
// carries — so a stalled dispatch would be unjoinable to a price, a
|
|
26208
|
+
// provider or another stall on a different model.
|
|
26209
|
+
model_requested: route.model,
|
|
26210
|
+
output_file: (ctx.journal && ctx.journal.progress) || null,
|
|
26211
|
+
captured_bytes: captured === null ? null : Buffer.byteLength(captured, "utf8"),
|
|
26212
|
+
timeline: {
|
|
26213
|
+
first_byte_ms: r.first_byte_ms,
|
|
26214
|
+
last_progress_ms: r.last_progress_ms,
|
|
26215
|
+
longest_gap_ms: r.longest_gap_ms,
|
|
26216
|
+
quiet_for_ms: r.stall_ms,
|
|
26217
|
+
stall_budget_ms: timeouts.stall_ms,
|
|
26218
|
+
wall_budget_ms: timeouts.wall_ms,
|
|
26219
|
+
},
|
|
26220
|
+
};
|
|
26221
|
+
// THE STALL, NAMED. Reported before the wall clock because it is the more
|
|
26222
|
+
// specific fact: a stall is a POSITION to resume from, a wall-clock timeout
|
|
26223
|
+
// is a budget to raise, and calling the first one the second is what sent
|
|
26224
|
+
// fifteen-minute stalls to a from-scratch Claude fallback.
|
|
26225
|
+
if (r.stopped_by === "stall")
|
|
26226
|
+
return fail(
|
|
26227
|
+
"stalled",
|
|
26228
|
+
`${cli.bin} produced nothing for ${Math.round(r.stall_ms / 1000)}s (the stall budget is ${Math.round(
|
|
26229
|
+
timeouts.stall_ms / 1000
|
|
26230
|
+
)}s) — no output, no stderr and no declared file changed. The process was still running; it had stopped working. ` +
|
|
26231
|
+
"`orc extra reconcile` has the position it left, and `extra_resume` continues from it instead of starting over.",
|
|
26232
|
+
stopEvidence
|
|
26233
|
+
);
|
|
26234
|
+
if (r.stopped_by === "wall")
|
|
26235
|
+
return fail("timeout", `no answer in ${Math.round(timeouts.wall_ms / 1000)}s (the dispatch wall clock).`, stopEvidence);
|
|
25506
26236
|
if (r.error) return fail("spawn-failed", r.error.message, { adapter: cli.bin, argv: argvUsed });
|
|
25507
26237
|
|
|
25508
26238
|
let lastMessage = null;
|
|
25509
26239
|
try {
|
|
25510
26240
|
lastMessage = fs.readFileSync(outFile, "utf8");
|
|
25511
26241
|
} catch (_) {}
|
|
25512
|
-
out = { stdout: childOut, stderr: r.stderr || "", status: r.status, lastMessage };
|
|
26242
|
+
out = { stdout: childOut, stderr: r.stderr || "", status: r.status, lastMessage, timeline: stopEvidence.timeline };
|
|
25513
26243
|
} finally {
|
|
25514
26244
|
try {
|
|
25515
26245
|
fs.rmSync(wdir, { recursive: true, force: true });
|
|
@@ -25584,6 +26314,9 @@ function runCliEngine(ctx) {
|
|
|
25584
26314
|
},
|
|
25585
26315
|
preflight_warning: preflightWarn,
|
|
25586
26316
|
preflight_remedy: preflightRemedy,
|
|
26317
|
+
// The same timeline the stall report carries, on EVERY outcome. A budget you
|
|
26318
|
+
// can only see when it fires is a budget nobody can set before it does.
|
|
26319
|
+
timeline: out.timeline || null,
|
|
25587
26320
|
};
|
|
25588
26321
|
|
|
25589
26322
|
// F14f — THE PROVIDER'S OWN ERROR OBJECT FIRST, the stderr patterns as the
|
|
@@ -25889,7 +26622,7 @@ async function extraDispatch(claudeDir) {
|
|
|
25889
26622
|
// dispatch that named only the model would run at whatever that user's
|
|
25890
26623
|
// config happens to say — a silent downgrade, the exact failure class the
|
|
25891
26624
|
// `expect=<model>/<effort>` trace design exists to catch.
|
|
25892
|
-
out = runCliEngine({
|
|
26625
|
+
out = await runCliEngine({
|
|
25893
26626
|
prof,
|
|
25894
26627
|
route: Object.assign({}, route, { effort: route.effort || extraEffortFromAgent(res.claude && res.claude.agent) }),
|
|
25895
26628
|
key: cred.value,
|
|
@@ -25932,6 +26665,9 @@ async function extraDispatch(claudeDir) {
|
|
|
25932
26665
|
engine: prof.engine,
|
|
25933
26666
|
declared_files: slice.declared_files || [],
|
|
25934
26667
|
fallback_to: res.claude,
|
|
26668
|
+
// v0.56.1 — the same agent, plus WHETHER THE USER GETS A SAY. `fallback_to`
|
|
26669
|
+
// keeps its name, its position and its meaning; this is beside it.
|
|
26670
|
+
fallback: extraFallbackChoice(cfg, res.claude),
|
|
25935
26671
|
announce: res.announce,
|
|
25936
26672
|
// v0.53.3 — WHAT WAS ACTUALLY SENT, not what the profile declares. The
|
|
25937
26673
|
// return used to copy `credential.source: "vault"` off the profile while
|
|
@@ -26075,8 +26811,17 @@ function extraTraceExtras(p) {
|
|
|
26075
26811
|
// The fallback line is the CALLER's to emit AFTER it re-dispatches, because
|
|
26076
26812
|
// only the caller knows whether it did. What the CLI can honestly supply is
|
|
26077
26813
|
// the text, pre-composed, so the two wordings cannot diverge.
|
|
26078
|
-
|
|
26079
|
-
|
|
26814
|
+
//
|
|
26815
|
+
// v0.56.1 — under `extra_fallback_agent: ask` NOTHING HAS BEEN CHOSEN YET, so
|
|
26816
|
+
// the line says `pending` rather than naming the band's agent. A trace that
|
|
26817
|
+
// asserts an agent the user has not picked is a decision /orc-retro would
|
|
26818
|
+
// aggregate as one that was made.
|
|
26819
|
+
if (!p.ok) {
|
|
26820
|
+
const chosen = extraFallbackAgentOf(p);
|
|
26821
|
+
if (p.fallback && p.fallback.mode === "ask")
|
|
26822
|
+
out.push(`EXTRA fallback task=${p.task_id || "?"} :: ${p.reason} → pending (extra_fallback_agent=ask)`);
|
|
26823
|
+
else if (chosen) out.push(`EXTRA fallback task=${p.task_id || "?"} :: ${p.reason} → ${chosen}`);
|
|
26824
|
+
}
|
|
26080
26825
|
return out;
|
|
26081
26826
|
}
|
|
26082
26827
|
|
|
@@ -26136,6 +26881,30 @@ function extraDispatchRender(p) {
|
|
|
26136
26881
|
if (p.credential_hint) console.log(" " + ui.mark.warn(p.credential_hint));
|
|
26137
26882
|
if (!p.ok && p.fallback_to)
|
|
26138
26883
|
console.log(ui.color.gray(` fallback: ${p.fallback_to.agent} ${p.fallback_to.band}`));
|
|
26884
|
+
// v0.56.1 — the TIMELINE, on every engine-cli outcome. A stall budget you can
|
|
26885
|
+
// only see when it fires is a budget nobody can set before it does.
|
|
26886
|
+
if (p.timeline && p.timeline.stall_budget_ms) {
|
|
26887
|
+
const sec = (ms) => (ms === null || ms === undefined ? "—" : `${Math.round(ms / 1000)}s`);
|
|
26888
|
+
console.log(
|
|
26889
|
+
ui.color.gray(
|
|
26890
|
+
` timeline: first byte ${sec(p.timeline.first_byte_ms)} · last progress ${sec(
|
|
26891
|
+
p.timeline.last_progress_ms
|
|
26892
|
+
)} · longest quiet gap ${sec(p.timeline.longest_gap_ms)} · stall budget ${sec(p.timeline.stall_budget_ms)}`
|
|
26893
|
+
)
|
|
26894
|
+
);
|
|
26895
|
+
}
|
|
26896
|
+
// The MENU, when the user asked to be the one who picks. Printed in full,
|
|
26897
|
+
// because a lane that has to go and look the options up is a lane that will
|
|
26898
|
+
// pick the first one.
|
|
26899
|
+
if (!p.ok && p.fallback && p.fallback.mode === "ask") {
|
|
26900
|
+
console.log("");
|
|
26901
|
+
console.log(ui.color.yellow(" extra_fallback_agent is `ask` — choose who picks this task up:"));
|
|
26902
|
+
for (const o of p.fallback.options)
|
|
26903
|
+
console.log(` ${ui.color.cyan(o.agent)}${o.is_band ? ui.color.gray(" (this task's own band)") : ""} ${ui.color.gray(o.why)}`);
|
|
26904
|
+
if (p.fallback.free_text) console.log(ui.color.gray(` …or ${p.fallback.free_text}`));
|
|
26905
|
+
} else if (!p.ok && p.fallback && p.fallback.mode === "pinned") {
|
|
26906
|
+
console.log(ui.color.gray(` ${p.fallback.note}`));
|
|
26907
|
+
}
|
|
26139
26908
|
// Copy this into the phase packet verbatim. It is printed rather than only
|
|
26140
26909
|
// returned in --json because the orchestrator reads the human output too, and
|
|
26141
26910
|
// a line it has to retype is a line it will retype differently.
|
|
@@ -27335,6 +28104,12 @@ function extraUsage() {
|
|
|
27335
28104
|
" orc extra lanes [--json] WHICH LANE each band governs. A\n" +
|
|
27336
28105
|
" fixed-executor lane shows both edges of its\n" +
|
|
27337
28106
|
" pinned agent's band and whether they agreed.\n" +
|
|
28107
|
+
" orc extra health <profile> [--model <id>] [--json]\n" +
|
|
28108
|
+
" DOES THIS MODEL STALL. Runs the live probe through the\n" +
|
|
28109
|
+
" SAME watchdog a dispatch uses and reports the timeline:\n" +
|
|
28110
|
+
" first byte, longest quiet gap, and whether it answered,\n" +
|
|
28111
|
+
" stalled or ran out of wall clock. Engine `cli` only.\n" +
|
|
28112
|
+
" 0 answered - 1 stalled or failed - 2 unknown profile\n" +
|
|
27338
28113
|
" orc extra preflight [--json] the P0 gate before wave 1.\n" +
|
|
27339
28114
|
" 0 ok · 1 STOP (an expired or missing passphrase on a\n" +
|
|
27340
28115
|
" vaulted profile a route row names)\n" +
|
|
@@ -27740,6 +28515,10 @@ async function extra() {
|
|
|
27740
28515
|
case "preflight":
|
|
27741
28516
|
extraPreflight(claudeDir);
|
|
27742
28517
|
break;
|
|
28518
|
+
// v0.56.1 - the stall clock, measurable BEFORE a wave depends on it.
|
|
28519
|
+
case "health":
|
|
28520
|
+
await extraHealth(claudeDir, pos[2]);
|
|
28521
|
+
break;
|
|
27743
28522
|
case "route":
|
|
27744
28523
|
if (pos[2] === "set") extraRouteSet(claudeDir, pos[3], pos[4]);
|
|
27745
28524
|
else if (pos[2] === "rm") extraRouteRm(claudeDir, pos[3]);
|
|
@@ -28063,6 +28842,32 @@ function doctor() {
|
|
|
28063
28842
|
else if (docList(claudeDir).length) ok(`${plural(docList(claudeDir).length, "document")}, none drifted`);
|
|
28064
28843
|
} catch (_) {}
|
|
28065
28844
|
|
|
28845
|
+
// 6b) The legacy global package (v0.56.0). This is not a `.claude/` problem —
|
|
28846
|
+
// it is a problem with the TOOL ITSELF, and it is the one finding that
|
|
28847
|
+
// explains why `orc upgrade` cannot fix anything else in this report. It is
|
|
28848
|
+
// NOT fixable by `orc doctor --fix`: that command's whole blast radius is
|
|
28849
|
+
// this project's .claude/, and evicting a global npm package is neither
|
|
28850
|
+
// project-scoped nor something to do without saying so. `orc upgrade` does it
|
|
28851
|
+
// (announced), so the fix command points there.
|
|
28852
|
+
try {
|
|
28853
|
+
const legacyOwner = detectLegacyBinOwner();
|
|
28854
|
+
if (legacyOwner)
|
|
28855
|
+
warn(
|
|
28856
|
+
"legacy-global-package",
|
|
28857
|
+
`${legacyOwner.name}@${legacyOwner.version} is installed globally and owns the \`orc\` ` +
|
|
28858
|
+
`command, but this package is now ${PKG_NAME} — npm refuses to link \`orc\` over it, ` +
|
|
28859
|
+
"so EVERY install source fails with the same EEXIST. Run `orc upgrade` (it removes the " +
|
|
28860
|
+
`old package first), or by hand: \`npm uninstall -g ${legacyOwner.name} && npm i -g ${NPM_SPEC}\``,
|
|
28861
|
+
{
|
|
28862
|
+
legacy_name: legacyOwner.name,
|
|
28863
|
+
legacy_version: legacyOwner.version,
|
|
28864
|
+
legacy_dir: legacyOwner.dir,
|
|
28865
|
+
package_name: PKG_NAME,
|
|
28866
|
+
fix_command: "orc upgrade",
|
|
28867
|
+
}
|
|
28868
|
+
);
|
|
28869
|
+
} catch (_) {}
|
|
28870
|
+
|
|
28066
28871
|
// 7) `orc extra` (v0.50.0). ONE line here, carrying the COUNT and the
|
|
28067
28872
|
// command that itemises it — the restraint is deliberate and the same call
|
|
28068
28873
|
// the two wiki findings made: a doctor that recites eleven finding ids for a
|