@harness-engineering/orchestrator 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +114 -4
- package/dist/index.d.ts +114 -4
- package/dist/index.js +771 -50
- package/dist/index.mjs +778 -47
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -3881,6 +3881,7 @@ var AgentRunner = class {
|
|
|
3881
3881
|
};
|
|
3882
3882
|
|
|
3883
3883
|
// src/agent/local-model-resolver.ts
|
|
3884
|
+
var import_local_models = require("@harness-engineering/local-models");
|
|
3884
3885
|
var DEFAULT_PROBE_INTERVAL_MS = 3e4;
|
|
3885
3886
|
var MIN_PROBE_INTERVAL_MS = 1e3;
|
|
3886
3887
|
var DEFAULT_API_KEY = "lm-studio";
|
|
@@ -3937,6 +3938,7 @@ var LocalModelResolver = class {
|
|
|
3937
3938
|
endpoint;
|
|
3938
3939
|
apiKey;
|
|
3939
3940
|
configured;
|
|
3941
|
+
poolState;
|
|
3940
3942
|
probeIntervalMs;
|
|
3941
3943
|
fetchModels;
|
|
3942
3944
|
logger;
|
|
@@ -3965,6 +3967,9 @@ var LocalModelResolver = class {
|
|
|
3965
3967
|
this.apiKey = opts.apiKey;
|
|
3966
3968
|
}
|
|
3967
3969
|
this.configured = [...opts.configured];
|
|
3970
|
+
if (opts.poolState !== void 0) {
|
|
3971
|
+
this.poolState = opts.poolState;
|
|
3972
|
+
}
|
|
3968
3973
|
const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
|
|
3969
3974
|
this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
|
|
3970
3975
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
@@ -3978,13 +3983,21 @@ var LocalModelResolver = class {
|
|
|
3978
3983
|
return {
|
|
3979
3984
|
available: this.available,
|
|
3980
3985
|
resolved: this.resolved,
|
|
3981
|
-
configured:
|
|
3986
|
+
configured: this.candidates(),
|
|
3982
3987
|
detected: [...this.detected],
|
|
3983
3988
|
lastProbeAt: this.lastProbeAt,
|
|
3984
3989
|
lastError: this.lastError,
|
|
3985
3990
|
warnings: [...this.warnings]
|
|
3986
3991
|
};
|
|
3987
3992
|
}
|
|
3993
|
+
/**
|
|
3994
|
+
* Effective candidate list. With a poolState port present the list derives
|
|
3995
|
+
* from pool entries (currentScore desc → ollamaName); otherwise the static
|
|
3996
|
+
* `configured` list is returned unchanged (byte-identical to pre-Phase-4).
|
|
3997
|
+
*/
|
|
3998
|
+
candidates() {
|
|
3999
|
+
return this.poolState ? (0, import_local_models.poolStateToCandidates)(this.poolState.snapshot()) : [...this.configured];
|
|
4000
|
+
}
|
|
3988
4001
|
onStatusChange(handler) {
|
|
3989
4002
|
this.listeners.add(handler);
|
|
3990
4003
|
return () => {
|
|
@@ -4008,11 +4021,12 @@ var LocalModelResolver = class {
|
|
|
4008
4021
|
this.detected = [...detected];
|
|
4009
4022
|
this.lastError = null;
|
|
4010
4023
|
this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4011
|
-
const
|
|
4024
|
+
const candidates = this.candidates();
|
|
4025
|
+
const match = candidates.find((id) => detected.includes(id)) ?? null;
|
|
4012
4026
|
this.resolved = match;
|
|
4013
4027
|
this.available = match !== null;
|
|
4014
4028
|
this.warnings = match ? [] : [
|
|
4015
|
-
`No configured local model is loaded. Configured: [${
|
|
4029
|
+
`No configured local model is loaded. Configured: [${candidates.join(", ")}]. Detected: [${detected.join(", ")}].`
|
|
4016
4030
|
];
|
|
4017
4031
|
} catch (err) {
|
|
4018
4032
|
const message = err instanceof Error ? err.message : "probe failed";
|
|
@@ -4061,7 +4075,7 @@ var LocalModelResolver = class {
|
|
|
4061
4075
|
return JSON.stringify({
|
|
4062
4076
|
available: this.available,
|
|
4063
4077
|
resolved: this.resolved,
|
|
4064
|
-
configured: this.
|
|
4078
|
+
configured: this.candidates(),
|
|
4065
4079
|
detected: this.detected,
|
|
4066
4080
|
lastError: this.lastError,
|
|
4067
4081
|
warnings: this.warnings
|
|
@@ -4069,6 +4083,10 @@ var LocalModelResolver = class {
|
|
|
4069
4083
|
}
|
|
4070
4084
|
};
|
|
4071
4085
|
|
|
4086
|
+
// src/orchestrator.ts
|
|
4087
|
+
var import_local_models4 = require("@harness-engineering/local-models");
|
|
4088
|
+
var import_core17 = require("@harness-engineering/core");
|
|
4089
|
+
|
|
4072
4090
|
// src/agent/config-migration.ts
|
|
4073
4091
|
var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
|
|
4074
4092
|
var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
|
|
@@ -8395,10 +8413,10 @@ function checkDiff(diff) {
|
|
|
8395
8413
|
function deriveFindings(proposal) {
|
|
8396
8414
|
const findings = [];
|
|
8397
8415
|
findings.push(...checkName(proposal.content.name));
|
|
8398
|
-
if (proposal.
|
|
8416
|
+
if (proposal.skillKind === "new-skill") {
|
|
8399
8417
|
findings.push(...checkSkillYaml(proposal.content.skillYaml ?? ""));
|
|
8400
8418
|
findings.push(...checkSkillMd(proposal.content.skillMd ?? ""));
|
|
8401
|
-
} else if (proposal.
|
|
8419
|
+
} else if (proposal.skillKind === "refinement") {
|
|
8402
8420
|
findings.push(...checkDiff(proposal.content.diff ?? ""));
|
|
8403
8421
|
}
|
|
8404
8422
|
return findings;
|
|
@@ -8406,6 +8424,9 @@ function deriveFindings(proposal) {
|
|
|
8406
8424
|
async function runGate(projectPath, proposalId) {
|
|
8407
8425
|
const proposal = await (0, import_core8.getProposal)(projectPath, proposalId);
|
|
8408
8426
|
if (!proposal) throw new import_core8.ProposalNotFoundError(proposalId);
|
|
8427
|
+
if (proposal.kind !== "skill") {
|
|
8428
|
+
throw new GateRunError(`gate applies to skill proposals only (got ${proposal.kind})`);
|
|
8429
|
+
}
|
|
8409
8430
|
if (proposal.status === "approved" || proposal.status === "rejected") {
|
|
8410
8431
|
throw new GateRunError(
|
|
8411
8432
|
`proposal ${proposalId} is already ${proposal.status}; cannot re-run the gate`
|
|
@@ -8531,8 +8552,11 @@ async function promoteRefinement(projectPath, proposal) {
|
|
|
8531
8552
|
async function promote(projectPath, proposalId, decidedBy) {
|
|
8532
8553
|
const proposal = await (0, import_core9.getProposal)(projectPath, proposalId);
|
|
8533
8554
|
if (!proposal) throw new import_core9.ProposalNotFoundError(proposalId);
|
|
8555
|
+
if (proposal.kind !== "skill") {
|
|
8556
|
+
throw new PromotionError("only skill proposals are promotable to the catalog");
|
|
8557
|
+
}
|
|
8534
8558
|
assertGateReady(proposal);
|
|
8535
|
-
const out = proposal.
|
|
8559
|
+
const out = proposal.skillKind === "new-skill" ? await promoteNewSkill(projectPath, proposal) : await promoteRefinement(projectPath, proposal);
|
|
8536
8560
|
await (0, import_core9.updateProposal)(projectPath, proposalId, {
|
|
8537
8561
|
status: "approved",
|
|
8538
8562
|
decision: {
|
|
@@ -8555,7 +8579,7 @@ function emit3(bus, topic, data) {
|
|
|
8555
8579
|
function emitProposalCreated(bus, proposal) {
|
|
8556
8580
|
const data = {
|
|
8557
8581
|
id: proposal.id,
|
|
8558
|
-
kind: proposal.
|
|
8582
|
+
kind: proposal.skillKind,
|
|
8559
8583
|
name: proposal.content.name,
|
|
8560
8584
|
proposedBy: proposal.proposedBy,
|
|
8561
8585
|
justification: proposal.source.justification
|
|
@@ -8566,7 +8590,7 @@ function emitProposalCreated(bus, proposal) {
|
|
|
8566
8590
|
function emitProposalApproved(bus, proposal) {
|
|
8567
8591
|
const data = {
|
|
8568
8592
|
id: proposal.id,
|
|
8569
|
-
kind: proposal.
|
|
8593
|
+
kind: proposal.skillKind,
|
|
8570
8594
|
name: proposal.content.name,
|
|
8571
8595
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)"
|
|
8572
8596
|
};
|
|
@@ -8576,7 +8600,7 @@ function emitProposalApproved(bus, proposal) {
|
|
|
8576
8600
|
function emitProposalRejected(bus, proposal) {
|
|
8577
8601
|
const data = {
|
|
8578
8602
|
id: proposal.id,
|
|
8579
|
-
kind: proposal.
|
|
8603
|
+
kind: proposal.skillKind,
|
|
8580
8604
|
name: proposal.content.name,
|
|
8581
8605
|
decidedBy: proposal.decision?.decidedBy ?? "(unknown)",
|
|
8582
8606
|
reason: proposal.decision?.reason ?? "(no reason given)"
|
|
@@ -8584,6 +8608,163 @@ function emitProposalRejected(bus, proposal) {
|
|
|
8584
8608
|
emit3(bus, "proposal.rejected", data);
|
|
8585
8609
|
}
|
|
8586
8610
|
|
|
8611
|
+
// src/proposals/model-handlers.ts
|
|
8612
|
+
function isInUse(deps, ollamaName) {
|
|
8613
|
+
return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
|
|
8614
|
+
}
|
|
8615
|
+
var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
|
|
8616
|
+
var MODEL_POOL_TOPIC = "local-models:pool";
|
|
8617
|
+
function decisionOf(deps, action, reason) {
|
|
8618
|
+
return {
|
|
8619
|
+
decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
8620
|
+
decidedBy: deps.decidedBy ?? "orchestrator",
|
|
8621
|
+
action,
|
|
8622
|
+
...reason !== void 0 ? { reason } : {}
|
|
8623
|
+
};
|
|
8624
|
+
}
|
|
8625
|
+
function emitApproved(deps, proposal) {
|
|
8626
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8627
|
+
id: proposal.id,
|
|
8628
|
+
status: "approved",
|
|
8629
|
+
action: proposal.model.action,
|
|
8630
|
+
target: proposal.model.target.ollamaName
|
|
8631
|
+
});
|
|
8632
|
+
}
|
|
8633
|
+
async function deferEviction(deps, proposal, deferredName, event, evicted) {
|
|
8634
|
+
deps.pool.markPendingEviction(deferredName);
|
|
8635
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8636
|
+
status: "approved",
|
|
8637
|
+
decision: decisionOf(deps, "approved")
|
|
8638
|
+
});
|
|
8639
|
+
deps.bus.emit(MODEL_POOL_TOPIC, event);
|
|
8640
|
+
emitApproved(deps, proposal);
|
|
8641
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8642
|
+
}
|
|
8643
|
+
async function onApproveModelProposal(deps, proposal) {
|
|
8644
|
+
const { model } = proposal;
|
|
8645
|
+
if (model.action === "evict") {
|
|
8646
|
+
return applyEvictOnly(deps, proposal);
|
|
8647
|
+
}
|
|
8648
|
+
const installResult = await deps.pool.install({
|
|
8649
|
+
hfRepoId: model.target.hfRepoId,
|
|
8650
|
+
ollamaName: model.target.ollamaName,
|
|
8651
|
+
...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
|
|
8652
|
+
...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
|
|
8653
|
+
});
|
|
8654
|
+
if (installResult.status === "error") {
|
|
8655
|
+
if (installResult.code === "failed_target_missing") {
|
|
8656
|
+
const updated2 = await deps.updateProposal(proposal.id, {
|
|
8657
|
+
status: "failed_target_missing"
|
|
8658
|
+
});
|
|
8659
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8660
|
+
id: proposal.id,
|
|
8661
|
+
status: "failed_target_missing",
|
|
8662
|
+
action: model.action,
|
|
8663
|
+
target: model.target.ollamaName
|
|
8664
|
+
});
|
|
8665
|
+
return { status: "failed_target_missing", proposal: updated2 };
|
|
8666
|
+
}
|
|
8667
|
+
return { status: "error", code: installResult.code, message: installResult.message };
|
|
8668
|
+
}
|
|
8669
|
+
const evicted = [...installResult.evicted];
|
|
8670
|
+
if (model.action === "swap" && model.replaces !== void 0) {
|
|
8671
|
+
const replacesName = model.replaces.ollamaName;
|
|
8672
|
+
if (isInUse(deps, replacesName)) {
|
|
8673
|
+
return deferEviction(
|
|
8674
|
+
deps,
|
|
8675
|
+
proposal,
|
|
8676
|
+
replacesName,
|
|
8677
|
+
{
|
|
8678
|
+
id: proposal.id,
|
|
8679
|
+
action: model.action,
|
|
8680
|
+
installed: model.target.ollamaName,
|
|
8681
|
+
phase: "evict_deferred",
|
|
8682
|
+
deferred: replacesName,
|
|
8683
|
+
...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
|
|
8684
|
+
},
|
|
8685
|
+
evicted
|
|
8686
|
+
);
|
|
8687
|
+
}
|
|
8688
|
+
const evictResult = await deps.pool.evict({ ollamaName: replacesName });
|
|
8689
|
+
if (evictResult.status === "error") {
|
|
8690
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8691
|
+
id: proposal.id,
|
|
8692
|
+
action: model.action,
|
|
8693
|
+
installed: model.target.ollamaName,
|
|
8694
|
+
phase: "swap_evict_failed"
|
|
8695
|
+
});
|
|
8696
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8697
|
+
}
|
|
8698
|
+
if (evictResult.removed !== null) {
|
|
8699
|
+
evicted.push(evictResult.removed);
|
|
8700
|
+
}
|
|
8701
|
+
}
|
|
8702
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8703
|
+
status: "approved",
|
|
8704
|
+
decision: decisionOf(deps, "approved")
|
|
8705
|
+
});
|
|
8706
|
+
const evictedNames = [
|
|
8707
|
+
...installResult.evicted.map((e) => e.ollamaName),
|
|
8708
|
+
...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
|
|
8709
|
+
];
|
|
8710
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8711
|
+
id: proposal.id,
|
|
8712
|
+
action: model.action,
|
|
8713
|
+
installed: model.target.ollamaName,
|
|
8714
|
+
...evictedNames.length > 0 ? { evicted: evictedNames } : {}
|
|
8715
|
+
});
|
|
8716
|
+
emitApproved(deps, proposal);
|
|
8717
|
+
return { status: "approved", proposal: updated, evicted };
|
|
8718
|
+
}
|
|
8719
|
+
async function applyEvictOnly(deps, proposal) {
|
|
8720
|
+
const targetName = proposal.model.target.ollamaName;
|
|
8721
|
+
if (isInUse(deps, targetName)) {
|
|
8722
|
+
return deferEviction(
|
|
8723
|
+
deps,
|
|
8724
|
+
proposal,
|
|
8725
|
+
targetName,
|
|
8726
|
+
{ id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
|
|
8727
|
+
[]
|
|
8728
|
+
);
|
|
8729
|
+
}
|
|
8730
|
+
const evictResult = await deps.pool.evict({ ollamaName: targetName });
|
|
8731
|
+
if (evictResult.status === "error") {
|
|
8732
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
8733
|
+
}
|
|
8734
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8735
|
+
status: "approved",
|
|
8736
|
+
decision: decisionOf(deps, "approved")
|
|
8737
|
+
});
|
|
8738
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
8739
|
+
id: proposal.id,
|
|
8740
|
+
action: "evict",
|
|
8741
|
+
// XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
|
|
8742
|
+
// list multiple removals) — the evict-only path wraps its single removal in
|
|
8743
|
+
// an array too, so consumers never branch on string-vs-array.
|
|
8744
|
+
evicted: [proposal.model.target.ollamaName]
|
|
8745
|
+
});
|
|
8746
|
+
emitApproved(deps, proposal);
|
|
8747
|
+
return {
|
|
8748
|
+
status: "approved",
|
|
8749
|
+
proposal: updated,
|
|
8750
|
+
evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
|
|
8751
|
+
};
|
|
8752
|
+
}
|
|
8753
|
+
async function onRejectModelProposal(deps, proposal, reason) {
|
|
8754
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
8755
|
+
status: "rejected",
|
|
8756
|
+
decision: decisionOf(deps, "rejected", reason)
|
|
8757
|
+
});
|
|
8758
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
8759
|
+
id: proposal.id,
|
|
8760
|
+
status: "rejected",
|
|
8761
|
+
target: proposal.model.target.ollamaName,
|
|
8762
|
+
replaces: proposal.model.replaces?.ollamaName,
|
|
8763
|
+
reason
|
|
8764
|
+
});
|
|
8765
|
+
return updated;
|
|
8766
|
+
}
|
|
8767
|
+
|
|
8587
8768
|
// src/server/routes/v1/proposals.ts
|
|
8588
8769
|
var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
|
|
8589
8770
|
var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -8609,6 +8790,20 @@ function getDecidedBy(req, deps) {
|
|
|
8609
8790
|
const token = req._authToken;
|
|
8610
8791
|
return token?.id ?? "unknown";
|
|
8611
8792
|
}
|
|
8793
|
+
function modelHandlerDeps(deps, req) {
|
|
8794
|
+
return {
|
|
8795
|
+
pool: deps.modelPool,
|
|
8796
|
+
bus: deps.bus,
|
|
8797
|
+
// Core's `updateProposal` patch type intersects the skill+model status
|
|
8798
|
+
// enums, which statically excludes model-only statuses like
|
|
8799
|
+
// `failed_target_missing`. The runtime parses through `ProposalSchema` (the
|
|
8800
|
+
// model variant accepts that status), so the cast is sound — only the
|
|
8801
|
+
// intersection type is too narrow.
|
|
8802
|
+
updateProposal: (id, patch) => (0, import_core10.updateProposal)(deps.projectPath, id, patch),
|
|
8803
|
+
decidedBy: getDecidedBy(req, deps),
|
|
8804
|
+
...deps.isModelInUse ? { isModelInUse: deps.isModelInUse } : {}
|
|
8805
|
+
};
|
|
8806
|
+
}
|
|
8612
8807
|
function parseStatusFromQuery(url) {
|
|
8613
8808
|
const queryIdx = url.indexOf("?");
|
|
8614
8809
|
if (queryIdx === -1) return void 0;
|
|
@@ -8653,11 +8848,31 @@ async function handleRunGate(res, deps, id) {
|
|
|
8653
8848
|
}
|
|
8654
8849
|
}
|
|
8655
8850
|
async function handleApprove(req, res, deps, id) {
|
|
8851
|
+
const existing = await (0, import_core10.getProposal)(deps.projectPath, id);
|
|
8852
|
+
if (!existing) {
|
|
8853
|
+
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8854
|
+
return;
|
|
8855
|
+
}
|
|
8856
|
+
if (existing.kind === "model") {
|
|
8857
|
+
if (!deps.modelPool) {
|
|
8858
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8859
|
+
return;
|
|
8860
|
+
}
|
|
8861
|
+
if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
|
|
8862
|
+
sendJSON8(res, 409, {
|
|
8863
|
+
error: `proposal already ${existing.status}; cannot approve`
|
|
8864
|
+
});
|
|
8865
|
+
return;
|
|
8866
|
+
}
|
|
8867
|
+
const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
|
|
8868
|
+
sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
|
|
8869
|
+
return;
|
|
8870
|
+
}
|
|
8656
8871
|
const decidedBy = getDecidedBy(req, deps);
|
|
8657
8872
|
try {
|
|
8658
8873
|
const result = await promote(deps.projectPath, id, decidedBy);
|
|
8659
8874
|
const proposal = await (0, import_core10.getProposal)(deps.projectPath, id);
|
|
8660
|
-
if (proposal) emitProposalApproved(deps.bus, proposal);
|
|
8875
|
+
if (proposal && proposal.kind === "skill") emitProposalApproved(deps.bus, proposal);
|
|
8661
8876
|
sendJSON8(res, 200, { promotion: result, proposal });
|
|
8662
8877
|
} catch (err) {
|
|
8663
8878
|
if (err instanceof import_core10.ProposalNotFoundError) {
|
|
@@ -8709,6 +8924,19 @@ async function handleReject(req, res, deps, id) {
|
|
|
8709
8924
|
});
|
|
8710
8925
|
return;
|
|
8711
8926
|
}
|
|
8927
|
+
if (proposal.kind === "model") {
|
|
8928
|
+
if (!deps.modelPool) {
|
|
8929
|
+
sendJSON8(res, 501, { error: "model proposal handlers not configured" });
|
|
8930
|
+
return;
|
|
8931
|
+
}
|
|
8932
|
+
const updated2 = await onRejectModelProposal(
|
|
8933
|
+
modelHandlerDeps(deps, req),
|
|
8934
|
+
proposal,
|
|
8935
|
+
parsed.data.reason
|
|
8936
|
+
);
|
|
8937
|
+
sendJSON8(res, 200, updated2);
|
|
8938
|
+
return;
|
|
8939
|
+
}
|
|
8712
8940
|
const decidedBy = getDecidedBy(req, deps);
|
|
8713
8941
|
const updated = await (0, import_core10.updateProposal)(deps.projectPath, id, {
|
|
8714
8942
|
status: "rejected",
|
|
@@ -8719,7 +8947,7 @@ async function handleReject(req, res, deps, id) {
|
|
|
8719
8947
|
reason: parsed.data.reason
|
|
8720
8948
|
}
|
|
8721
8949
|
});
|
|
8722
|
-
emitProposalRejected(deps.bus, updated);
|
|
8950
|
+
if (updated.kind === "skill") emitProposalRejected(deps.bus, updated);
|
|
8723
8951
|
sendJSON8(res, 200, updated);
|
|
8724
8952
|
}
|
|
8725
8953
|
async function handleEdit(req, res, deps, id) {
|
|
@@ -8747,6 +8975,10 @@ async function handleEdit(req, res, deps, id) {
|
|
|
8747
8975
|
sendJSON8(res, 404, { error: "Proposal not found" });
|
|
8748
8976
|
return;
|
|
8749
8977
|
}
|
|
8978
|
+
if (existing.kind !== "skill") {
|
|
8979
|
+
sendJSON8(res, 422, { error: "edit applies to skill proposals only" });
|
|
8980
|
+
return;
|
|
8981
|
+
}
|
|
8750
8982
|
if (existing.status === "approved" || existing.status === "rejected") {
|
|
8751
8983
|
sendJSON8(res, 409, {
|
|
8752
8984
|
error: `proposal already ${existing.status}; cannot edit`
|
|
@@ -8812,17 +9044,159 @@ function handleV1ProposalsRoute(req, res, deps) {
|
|
|
8812
9044
|
return false;
|
|
8813
9045
|
}
|
|
8814
9046
|
|
|
9047
|
+
// src/server/routes/v1/local-models.ts
|
|
9048
|
+
var import_local_models2 = require("@harness-engineering/local-models");
|
|
9049
|
+
var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
|
|
9050
|
+
var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
|
|
9051
|
+
var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
|
|
9052
|
+
var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
|
|
9053
|
+
var PROPOSALS_RE = /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/;
|
|
9054
|
+
var RECOMMENDATION_PROFILES = ["general", "coding", "reasoning"];
|
|
9055
|
+
function sendJSON9(res, status, body) {
|
|
9056
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9057
|
+
res.end(JSON.stringify(body));
|
|
9058
|
+
}
|
|
9059
|
+
function handleV1LocalModelsRoute(req, res, deps) {
|
|
9060
|
+
const url = req.url ?? "";
|
|
9061
|
+
const method = req.method ?? "GET";
|
|
9062
|
+
if (method === "GET") {
|
|
9063
|
+
if (HARDWARE_RE.test(url)) {
|
|
9064
|
+
void runGet(res, () => handleHardware(res, deps));
|
|
9065
|
+
return true;
|
|
9066
|
+
}
|
|
9067
|
+
if (POOL_RE.test(url)) {
|
|
9068
|
+
void runGet(res, () => handlePool(res, deps));
|
|
9069
|
+
return true;
|
|
9070
|
+
}
|
|
9071
|
+
if (RECS_RE.test(url)) {
|
|
9072
|
+
void runGet(res, () => handleRecommendations(res, url, deps));
|
|
9073
|
+
return true;
|
|
9074
|
+
}
|
|
9075
|
+
if (PROPOSALS_RE.test(url)) {
|
|
9076
|
+
void runGet(res, () => handleProposals(res, deps));
|
|
9077
|
+
return true;
|
|
9078
|
+
}
|
|
9079
|
+
return false;
|
|
9080
|
+
}
|
|
9081
|
+
if (method !== "POST" || !REFRESH_RE.test(url)) return false;
|
|
9082
|
+
const scheduler = deps.getRefreshScheduler();
|
|
9083
|
+
if (scheduler === null) {
|
|
9084
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9085
|
+
return true;
|
|
9086
|
+
}
|
|
9087
|
+
void runForceRefresh(res, scheduler, deps);
|
|
9088
|
+
return true;
|
|
9089
|
+
}
|
|
9090
|
+
async function runGet(res, handler) {
|
|
9091
|
+
try {
|
|
9092
|
+
await handler();
|
|
9093
|
+
} catch (err) {
|
|
9094
|
+
sendJSON9(res, 500, {
|
|
9095
|
+
error: "local-models route failed",
|
|
9096
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9097
|
+
});
|
|
9098
|
+
}
|
|
9099
|
+
}
|
|
9100
|
+
async function handleHardware(res, deps) {
|
|
9101
|
+
const pending = deps.getHardwareProfile?.() ?? null;
|
|
9102
|
+
if (pending === null) {
|
|
9103
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9104
|
+
return;
|
|
9105
|
+
}
|
|
9106
|
+
sendJSON9(res, 200, await pending);
|
|
9107
|
+
}
|
|
9108
|
+
async function handlePool(res, deps) {
|
|
9109
|
+
const pool = deps.getModelPool?.() ?? null;
|
|
9110
|
+
if (pool === null) {
|
|
9111
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
sendJSON9(res, 200, pool.viewState?.() ?? pool.snapshot());
|
|
9115
|
+
}
|
|
9116
|
+
var MAX_RECOMMENDATION_TOP = 100;
|
|
9117
|
+
async function handleRecommendations(res, url, deps) {
|
|
9118
|
+
if (deps.getRecommendations === void 0) {
|
|
9119
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9120
|
+
return;
|
|
9121
|
+
}
|
|
9122
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
9123
|
+
const topRaw = params.get("top");
|
|
9124
|
+
let top = 10;
|
|
9125
|
+
if (topRaw !== null) {
|
|
9126
|
+
const n = Number(topRaw);
|
|
9127
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
9128
|
+
sendJSON9(res, 400, { error: "invalid top: expected a positive integer" });
|
|
9129
|
+
return;
|
|
9130
|
+
}
|
|
9131
|
+
if (n > MAX_RECOMMENDATION_TOP) {
|
|
9132
|
+
sendJSON9(res, 400, { error: `invalid top: exceeds maximum of ${MAX_RECOMMENDATION_TOP}` });
|
|
9133
|
+
return;
|
|
9134
|
+
}
|
|
9135
|
+
top = n;
|
|
9136
|
+
}
|
|
9137
|
+
const profileRaw = params.get("profile");
|
|
9138
|
+
let profile = "general";
|
|
9139
|
+
if (profileRaw !== null) {
|
|
9140
|
+
if (!isRecommendationProfile(profileRaw)) {
|
|
9141
|
+
sendJSON9(res, 400, {
|
|
9142
|
+
error: `invalid profile: expected one of ${RECOMMENDATION_PROFILES.join(", ")}`
|
|
9143
|
+
});
|
|
9144
|
+
return;
|
|
9145
|
+
}
|
|
9146
|
+
profile = profileRaw;
|
|
9147
|
+
}
|
|
9148
|
+
sendJSON9(res, 200, await deps.getRecommendations({ top, profile }));
|
|
9149
|
+
}
|
|
9150
|
+
async function handleProposals(res, deps) {
|
|
9151
|
+
if (deps.listModelProposals === void 0) {
|
|
9152
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9153
|
+
return;
|
|
9154
|
+
}
|
|
9155
|
+
sendJSON9(res, 200, await deps.listModelProposals());
|
|
9156
|
+
}
|
|
9157
|
+
function isRecommendationProfile(value) {
|
|
9158
|
+
return RECOMMENDATION_PROFILES.includes(value);
|
|
9159
|
+
}
|
|
9160
|
+
async function runForceRefresh(res, scheduler, deps) {
|
|
9161
|
+
let result;
|
|
9162
|
+
try {
|
|
9163
|
+
result = await scheduler.forceRefresh();
|
|
9164
|
+
} catch (err) {
|
|
9165
|
+
sendJSON9(res, 500, {
|
|
9166
|
+
error: "refresh tick failed",
|
|
9167
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9168
|
+
});
|
|
9169
|
+
return;
|
|
9170
|
+
}
|
|
9171
|
+
if ((0, import_local_models2.isTickHardFailure)(result)) {
|
|
9172
|
+
sendJSON9(res, 503, {
|
|
9173
|
+
error: "refresh hard failure: HuggingFace unreachable and no benchmark snapshot loaded",
|
|
9174
|
+
emitted: result.proposalsEmitted,
|
|
9175
|
+
warnings: result.warnings,
|
|
9176
|
+
errors: result.errors
|
|
9177
|
+
});
|
|
9178
|
+
return;
|
|
9179
|
+
}
|
|
9180
|
+
const proposals = deps.listModelProposals ? await deps.listModelProposals() : [];
|
|
9181
|
+
sendJSON9(res, 200, {
|
|
9182
|
+
emitted: result.proposalsEmitted,
|
|
9183
|
+
reconciledRemoved: result.reconciledRemoved,
|
|
9184
|
+
proposals,
|
|
9185
|
+
warnings: result.warnings
|
|
9186
|
+
});
|
|
9187
|
+
}
|
|
9188
|
+
|
|
8815
9189
|
// src/server/routes/v1/routing.ts
|
|
8816
9190
|
var import_zod14 = require("zod");
|
|
8817
9191
|
var CONFIG_RE = /^\/api\/v1\/routing\/config(?:\?.*)?$/;
|
|
8818
9192
|
var DECISIONS_RE = /^\/api\/v1\/routing\/decisions(?:\?.*)?$/;
|
|
8819
9193
|
var TRACE_RE = /^\/api\/v1\/routing\/trace(?:\?.*)?$/;
|
|
8820
|
-
function
|
|
9194
|
+
function sendJSON10(res, status, body) {
|
|
8821
9195
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
8822
9196
|
res.end(JSON.stringify(body));
|
|
8823
9197
|
}
|
|
8824
9198
|
function unavailable(res) {
|
|
8825
|
-
|
|
9199
|
+
sendJSON10(res, 503, { error: "BackendRouter not available" });
|
|
8826
9200
|
return true;
|
|
8827
9201
|
}
|
|
8828
9202
|
function resolveChain(value, backends) {
|
|
@@ -8859,7 +9233,7 @@ function buildResolvedChains(routing, backends) {
|
|
|
8859
9233
|
}
|
|
8860
9234
|
function handleConfig(res, deps) {
|
|
8861
9235
|
if (!deps.router || !deps.routing || !deps.backends) return unavailable(res);
|
|
8862
|
-
|
|
9236
|
+
sendJSON10(res, 200, {
|
|
8863
9237
|
routing: deps.routing,
|
|
8864
9238
|
resolvedChains: buildResolvedChains(deps.routing, deps.backends),
|
|
8865
9239
|
backends: Object.keys(deps.backends)
|
|
@@ -8887,7 +9261,7 @@ function parseDecisionsQuery(url) {
|
|
|
8887
9261
|
function handleDecisions(req, res, deps) {
|
|
8888
9262
|
if (!deps.bus) return unavailable(res);
|
|
8889
9263
|
const filter = parseDecisionsQuery(req.url ?? "");
|
|
8890
|
-
|
|
9264
|
+
sendJSON10(res, 200, { decisions: deps.bus.recent(filter) });
|
|
8891
9265
|
return true;
|
|
8892
9266
|
}
|
|
8893
9267
|
var UseCaseSchema = import_zod14.z.discriminatedUnion("kind", [
|
|
@@ -8919,19 +9293,19 @@ async function handleTrace(req, res, deps) {
|
|
|
8919
9293
|
try {
|
|
8920
9294
|
raw = await readBody(req);
|
|
8921
9295
|
} catch {
|
|
8922
|
-
|
|
9296
|
+
sendJSON10(res, 400, { error: "body read failed" });
|
|
8923
9297
|
return true;
|
|
8924
9298
|
}
|
|
8925
9299
|
let parsed;
|
|
8926
9300
|
try {
|
|
8927
9301
|
parsed = JSON.parse(raw);
|
|
8928
9302
|
} catch {
|
|
8929
|
-
|
|
9303
|
+
sendJSON10(res, 400, { error: "invalid JSON body" });
|
|
8930
9304
|
return true;
|
|
8931
9305
|
}
|
|
8932
9306
|
const r = TraceBodySchema.safeParse(parsed);
|
|
8933
9307
|
if (!r.success) {
|
|
8934
|
-
|
|
9308
|
+
sendJSON10(res, 400, { error: r.error.message });
|
|
8935
9309
|
return true;
|
|
8936
9310
|
}
|
|
8937
9311
|
const opts = r.data.invocationOverride !== void 0 ? { invocationOverride: r.data.invocationOverride } : void 0;
|
|
@@ -8944,9 +9318,9 @@ async function handleTrace(req, res, deps) {
|
|
|
8944
9318
|
r.data.useCase,
|
|
8945
9319
|
opts
|
|
8946
9320
|
);
|
|
8947
|
-
|
|
9321
|
+
sendJSON10(res, 200, { decision, def: { type: def.type } });
|
|
8948
9322
|
} catch (err) {
|
|
8949
|
-
|
|
9323
|
+
sendJSON10(res, 500, { error: String(err) });
|
|
8950
9324
|
}
|
|
8951
9325
|
return true;
|
|
8952
9326
|
}
|
|
@@ -9183,7 +9557,7 @@ var CreateBodySchema = import_zod16.z.object({
|
|
|
9183
9557
|
tenantId: import_zod16.z.string().optional(),
|
|
9184
9558
|
expiresAt: import_zod16.z.string().datetime().optional()
|
|
9185
9559
|
});
|
|
9186
|
-
function
|
|
9560
|
+
function sendJSON11(res, status, body) {
|
|
9187
9561
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9188
9562
|
res.end(JSON.stringify(body));
|
|
9189
9563
|
}
|
|
@@ -9193,19 +9567,19 @@ async function handlePost(req, res, store) {
|
|
|
9193
9567
|
raw = await readBody(req);
|
|
9194
9568
|
} catch (err) {
|
|
9195
9569
|
const msg = err instanceof Error ? err.message : "Failed to read body";
|
|
9196
|
-
|
|
9570
|
+
sendJSON11(res, 413, { error: msg });
|
|
9197
9571
|
return;
|
|
9198
9572
|
}
|
|
9199
9573
|
let json;
|
|
9200
9574
|
try {
|
|
9201
9575
|
json = JSON.parse(raw);
|
|
9202
9576
|
} catch {
|
|
9203
|
-
|
|
9577
|
+
sendJSON11(res, 400, { error: "Invalid JSON body" });
|
|
9204
9578
|
return;
|
|
9205
9579
|
}
|
|
9206
9580
|
const parsed = CreateBodySchema.safeParse(json);
|
|
9207
9581
|
if (!parsed.success) {
|
|
9208
|
-
|
|
9582
|
+
sendJSON11(res, 422, { error: "Invalid body", issues: parsed.error.issues });
|
|
9209
9583
|
return;
|
|
9210
9584
|
}
|
|
9211
9585
|
try {
|
|
@@ -9218,37 +9592,37 @@ async function handlePost(req, res, store) {
|
|
|
9218
9592
|
if (parsed.data.expiresAt !== void 0) input.expiresAt = parsed.data.expiresAt;
|
|
9219
9593
|
const result = await store.create(input);
|
|
9220
9594
|
const publicRecord = import_types25.AuthTokenPublicSchema.parse(result.record);
|
|
9221
|
-
|
|
9595
|
+
sendJSON11(res, 200, {
|
|
9222
9596
|
...publicRecord,
|
|
9223
9597
|
token: result.token
|
|
9224
9598
|
});
|
|
9225
9599
|
} catch (err) {
|
|
9226
9600
|
const msg = err instanceof Error ? err.message : "Failed to create token";
|
|
9227
9601
|
if (msg.includes("already exists")) {
|
|
9228
|
-
|
|
9602
|
+
sendJSON11(res, 409, { error: msg });
|
|
9229
9603
|
return;
|
|
9230
9604
|
}
|
|
9231
|
-
|
|
9605
|
+
sendJSON11(res, 500, { error: "Internal error creating token" });
|
|
9232
9606
|
}
|
|
9233
9607
|
}
|
|
9234
9608
|
async function handleList3(res, store) {
|
|
9235
9609
|
try {
|
|
9236
9610
|
const list = await store.list();
|
|
9237
|
-
|
|
9611
|
+
sendJSON11(res, 200, list);
|
|
9238
9612
|
} catch {
|
|
9239
|
-
|
|
9613
|
+
sendJSON11(res, 500, { error: "Internal error listing tokens" });
|
|
9240
9614
|
}
|
|
9241
9615
|
}
|
|
9242
9616
|
async function handleDelete2(res, store, id) {
|
|
9243
9617
|
try {
|
|
9244
9618
|
const ok = await store.revoke(id);
|
|
9245
9619
|
if (!ok) {
|
|
9246
|
-
|
|
9620
|
+
sendJSON11(res, 404, { error: "Token not found" });
|
|
9247
9621
|
return;
|
|
9248
9622
|
}
|
|
9249
|
-
|
|
9623
|
+
sendJSON11(res, 200, { deleted: true });
|
|
9250
9624
|
} catch {
|
|
9251
|
-
|
|
9625
|
+
sendJSON11(res, 500, { error: "Internal error revoking token" });
|
|
9252
9626
|
}
|
|
9253
9627
|
}
|
|
9254
9628
|
var DELETE_PATH_RE2 = /^\/api\/v1\/auth\/tokens\/([^/?]+)(?:\?.*)?$/;
|
|
@@ -9273,12 +9647,12 @@ function handleAuthRoute(req, res, store) {
|
|
|
9273
9647
|
return true;
|
|
9274
9648
|
}
|
|
9275
9649
|
}
|
|
9276
|
-
|
|
9650
|
+
sendJSON11(res, 405, { error: "Method not allowed" });
|
|
9277
9651
|
return true;
|
|
9278
9652
|
}
|
|
9279
9653
|
|
|
9280
9654
|
// src/server/routes/local-model.ts
|
|
9281
|
-
function
|
|
9655
|
+
function sendJSON12(res, status, body) {
|
|
9282
9656
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
9283
9657
|
res.end(JSON.stringify(body));
|
|
9284
9658
|
}
|
|
@@ -9286,30 +9660,30 @@ function handleLocalModelRoute(req, res, getStatus) {
|
|
|
9286
9660
|
const { method, url } = req;
|
|
9287
9661
|
if (url !== "/api/v1/local-model/status") return false;
|
|
9288
9662
|
if (method !== "GET") {
|
|
9289
|
-
|
|
9663
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9290
9664
|
return true;
|
|
9291
9665
|
}
|
|
9292
9666
|
if (!getStatus) {
|
|
9293
|
-
|
|
9667
|
+
sendJSON12(res, 503, { error: "Local backend not configured" });
|
|
9294
9668
|
return true;
|
|
9295
9669
|
}
|
|
9296
9670
|
const status = getStatus();
|
|
9297
9671
|
if (!status) {
|
|
9298
|
-
|
|
9672
|
+
sendJSON12(res, 503, { error: "Local backend not configured" });
|
|
9299
9673
|
return true;
|
|
9300
9674
|
}
|
|
9301
|
-
|
|
9675
|
+
sendJSON12(res, 200, status);
|
|
9302
9676
|
return true;
|
|
9303
9677
|
}
|
|
9304
9678
|
function handleLocalModelsRoute(req, res, getStatuses) {
|
|
9305
9679
|
const { method, url } = req;
|
|
9306
9680
|
if (url !== "/api/v1/local-models/status") return false;
|
|
9307
9681
|
if (method !== "GET") {
|
|
9308
|
-
|
|
9682
|
+
sendJSON12(res, 405, { error: "Method not allowed" });
|
|
9309
9683
|
return true;
|
|
9310
9684
|
}
|
|
9311
9685
|
const statuses = getStatuses ? getStatuses() : [];
|
|
9312
|
-
|
|
9686
|
+
sendJSON12(res, 200, statuses);
|
|
9313
9687
|
return true;
|
|
9314
9688
|
}
|
|
9315
9689
|
|
|
@@ -9673,6 +10047,45 @@ var V1_BRIDGE_ROUTES = [
|
|
|
9673
10047
|
scope: "read-telemetry",
|
|
9674
10048
|
description: "Prompt-cache hit/miss snapshot (rolling window)."
|
|
9675
10049
|
},
|
|
10050
|
+
// ── LMLM Phase 6 — force-refresh (background scheduler tick, out of band) ──
|
|
10051
|
+
// Reuses `manage-proposals`: a forced refresh emits model proposals, so the
|
|
10052
|
+
// same write scope that governs approve/reject governs triggering the tick.
|
|
10053
|
+
{
|
|
10054
|
+
method: "POST",
|
|
10055
|
+
pattern: /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/,
|
|
10056
|
+
scope: "manage-proposals",
|
|
10057
|
+
description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
|
|
10058
|
+
},
|
|
10059
|
+
// ── LMLM Phase 7 — read surface (hardware/pool/recommendations/proposals) ──
|
|
10060
|
+
// Load-bearing: `local-models` is in `V1_WRAPPABLE`, so without these entries
|
|
10061
|
+
// the `/api/v1` rewrite shim would rewrite `/api/v1/local-models/<name>` →
|
|
10062
|
+
// `/api/local-models/<name>` and misroute it to the legacy status handler.
|
|
10063
|
+
// `isV1Bridge` short-circuits the rewrite; `requiredBridgeScope` supplies the
|
|
10064
|
+
// default-deny read scope. All four are read-only observability (`read-status`).
|
|
10065
|
+
{
|
|
10066
|
+
method: "GET",
|
|
10067
|
+
pattern: /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/,
|
|
10068
|
+
scope: "read-status",
|
|
10069
|
+
description: "Current detected HardwareProfile (RAM/VRAM/GPU)."
|
|
10070
|
+
},
|
|
10071
|
+
{
|
|
10072
|
+
method: "GET",
|
|
10073
|
+
pattern: /^\/api\/v1\/local-models\/pool(?:\?.*)?$/,
|
|
10074
|
+
scope: "read-status",
|
|
10075
|
+
description: "Live PoolState view, including transient pendingEviction flags."
|
|
10076
|
+
},
|
|
10077
|
+
{
|
|
10078
|
+
method: "GET",
|
|
10079
|
+
pattern: /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/,
|
|
10080
|
+
scope: "read-status",
|
|
10081
|
+
description: "Hardware-ranked model recommendations (top/profile query params)."
|
|
10082
|
+
},
|
|
10083
|
+
{
|
|
10084
|
+
method: "GET",
|
|
10085
|
+
pattern: /^\/api\/v1\/local-models\/proposals(?:\?.*)?$/,
|
|
10086
|
+
scope: "read-status",
|
|
10087
|
+
description: "Open model-kind proposals (pending review queue)."
|
|
10088
|
+
},
|
|
9676
10089
|
// ── Spec B Phase 5 routing observability ──
|
|
9677
10090
|
// D-OP-1: all three reuse `read-telemetry` — matches the cacheMetrics
|
|
9678
10091
|
// precedent (read-only observability). A dedicated `read-routing`
|
|
@@ -9815,7 +10228,19 @@ var OrchestratorServer = class {
|
|
|
9815
10228
|
getRoutingDecisionBusFn = null;
|
|
9816
10229
|
getRoutingConfigFn = null;
|
|
9817
10230
|
getBackendsFn = null;
|
|
10231
|
+
// LMLM Phase 6 — live model pool + refresh scheduler accessors.
|
|
10232
|
+
getModelPoolFn = null;
|
|
10233
|
+
getRefreshSchedulerFn = null;
|
|
10234
|
+
// LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
|
|
10235
|
+
getHardwareProfileFn = null;
|
|
10236
|
+
getRecommendationsFn = null;
|
|
10237
|
+
listModelProposalsFn = null;
|
|
10238
|
+
// LMLM Phase 7 / S1 — in-use probe threaded into kind:'model' approve.
|
|
10239
|
+
isModelInUseFn = null;
|
|
9818
10240
|
routingDecisionUnsubscribe = null;
|
|
10241
|
+
// LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
|
|
10242
|
+
modelProposalListener = null;
|
|
10243
|
+
modelPoolListener = null;
|
|
9819
10244
|
recorder = null;
|
|
9820
10245
|
planWatcher = null;
|
|
9821
10246
|
tokenStore;
|
|
@@ -9860,6 +10285,12 @@ var OrchestratorServer = class {
|
|
|
9860
10285
|
this.getRoutingDecisionBusFn = deps?.getRoutingDecisionBus ?? null;
|
|
9861
10286
|
this.getRoutingConfigFn = deps?.getRoutingConfig ?? null;
|
|
9862
10287
|
this.getBackendsFn = deps?.getBackends ?? null;
|
|
10288
|
+
this.getModelPoolFn = deps?.getModelPool ?? null;
|
|
10289
|
+
this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
|
|
10290
|
+
this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
|
|
10291
|
+
this.getRecommendationsFn = deps?.getRecommendations ?? null;
|
|
10292
|
+
this.listModelProposalsFn = deps?.listModelProposals ?? null;
|
|
10293
|
+
this.isModelInUseFn = deps?.isModelInUse ?? null;
|
|
9863
10294
|
}
|
|
9864
10295
|
wireEvents() {
|
|
9865
10296
|
this.stateChangeListener = (snapshot) => {
|
|
@@ -9876,6 +10307,10 @@ var OrchestratorServer = class {
|
|
|
9876
10307
|
this.broadcaster.broadcast("routing:decision", decision);
|
|
9877
10308
|
});
|
|
9878
10309
|
}
|
|
10310
|
+
this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
|
|
10311
|
+
this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
|
|
10312
|
+
this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10313
|
+
this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
9879
10314
|
}
|
|
9880
10315
|
/**
|
|
9881
10316
|
* Broadcast a new interaction to all WebSocket clients.
|
|
@@ -10044,9 +10479,27 @@ var OrchestratorServer = class {
|
|
|
10044
10479
|
// upstream by V1_BRIDGE_ROUTES; this dispatcher only handles
|
|
10045
10480
|
// business logic. `projectPath` defaults to process.cwd() — that is
|
|
10046
10481
|
// where `.harness/proposals/` lives in every deployment we ship.
|
|
10047
|
-
(req, res) =>
|
|
10048
|
-
|
|
10049
|
-
|
|
10482
|
+
(req, res) => {
|
|
10483
|
+
const modelPool = this.getModelPoolFn?.() ?? null;
|
|
10484
|
+
return handleV1ProposalsRoute(req, res, {
|
|
10485
|
+
projectPath: this.projectPath,
|
|
10486
|
+
bus: this.orchestrator,
|
|
10487
|
+
...modelPool ? { modelPool } : {},
|
|
10488
|
+
// S1: thread the in-use probe so an approved swap/evict of a model an
|
|
10489
|
+
// agent could be using is deferred (marked pendingEviction) not applied.
|
|
10490
|
+
...this.isModelInUseFn ? { isModelInUse: this.isModelInUseFn } : {}
|
|
10491
|
+
});
|
|
10492
|
+
},
|
|
10493
|
+
// LMLM Phase 6/7 — POST /refresh + the GET read surface
|
|
10494
|
+
// (hardware/pool/recommendations/proposals). Registered before the
|
|
10495
|
+
// chat-proxy fallback so it owns the path. Each accessor is spread in
|
|
10496
|
+
// only when configured so absent ones surface as 503 (LMLM disabled).
|
|
10497
|
+
(req, res) => handleV1LocalModelsRoute(req, res, {
|
|
10498
|
+
getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
|
|
10499
|
+
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10500
|
+
...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
|
|
10501
|
+
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
10502
|
+
...this.listModelProposalsFn ? { listModelProposals: this.listModelProposalsFn } : {}
|
|
10050
10503
|
}),
|
|
10051
10504
|
// Chat proxy route (spawns Claude Code CLI — no API key required)
|
|
10052
10505
|
(req, res) => handleChatProxyRoute(req, res, this.claudeCommand)
|
|
@@ -10150,6 +10603,14 @@ var OrchestratorServer = class {
|
|
|
10150
10603
|
this.routingDecisionUnsubscribe();
|
|
10151
10604
|
this.routingDecisionUnsubscribe = null;
|
|
10152
10605
|
}
|
|
10606
|
+
if (this.modelProposalListener) {
|
|
10607
|
+
this.orchestrator.removeListener(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10608
|
+
this.modelProposalListener = null;
|
|
10609
|
+
}
|
|
10610
|
+
if (this.modelPoolListener) {
|
|
10611
|
+
this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10612
|
+
this.modelPoolListener = null;
|
|
10613
|
+
}
|
|
10153
10614
|
if (this.planWatcher) {
|
|
10154
10615
|
this.planWatcher.stop();
|
|
10155
10616
|
this.planWatcher = null;
|
|
@@ -11065,8 +11526,50 @@ var ENVELOPE_DERIVERS = {
|
|
|
11065
11526
|
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11066
11527
|
severity: "warning"
|
|
11067
11528
|
};
|
|
11068
|
-
}
|
|
11529
|
+
},
|
|
11530
|
+
// LMLM Phase 7 — model-proposal lifecycle. Delegated to a status-keyed
|
|
11531
|
+
// lookup (below) so each status branch is its own small function and the
|
|
11532
|
+
// deriver stays under the cyclomatic-complexity gate.
|
|
11533
|
+
"local-models.proposal": (event) => deriveModelProposalEnvelope(event)
|
|
11069
11534
|
};
|
|
11535
|
+
var MODEL_PROPOSAL_ENVELOPES = {
|
|
11536
|
+
created: (data) => {
|
|
11537
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11538
|
+
return {
|
|
11539
|
+
title: `New model proposal: ${label}`,
|
|
11540
|
+
summary: `A model proposal (${label}) is awaiting review.`,
|
|
11541
|
+
severity: "info"
|
|
11542
|
+
};
|
|
11543
|
+
},
|
|
11544
|
+
approved: (data) => {
|
|
11545
|
+
const label = `${data.action ?? "change"} ${data.target ?? "(unknown model)"}`;
|
|
11546
|
+
return {
|
|
11547
|
+
title: `Model proposal approved: ${label}`,
|
|
11548
|
+
summary: `The model proposal (${label}) was approved and applied to the pool.`,
|
|
11549
|
+
severity: "success"
|
|
11550
|
+
};
|
|
11551
|
+
},
|
|
11552
|
+
rejected: (data) => ({
|
|
11553
|
+
title: "Model proposal rejected",
|
|
11554
|
+
summary: truncate(data.reason ?? "No reason provided.", 240),
|
|
11555
|
+
severity: "warning"
|
|
11556
|
+
}),
|
|
11557
|
+
failed_target_missing: (data) => ({
|
|
11558
|
+
title: "Model proposal target missing",
|
|
11559
|
+
summary: `The proposed model ${data.target ?? "(unknown model)"} is no longer available; the proposal was closed.`,
|
|
11560
|
+
severity: "error"
|
|
11561
|
+
})
|
|
11562
|
+
};
|
|
11563
|
+
function deriveModelProposalEnvelope(event) {
|
|
11564
|
+
const data = asObj(event.data);
|
|
11565
|
+
const build = MODEL_PROPOSAL_ENVELOPES[data.status ?? ""];
|
|
11566
|
+
if (build) return build(data);
|
|
11567
|
+
return {
|
|
11568
|
+
title: `Model proposal update: ${data.target ?? "(unknown model)"}`,
|
|
11569
|
+
summary: `Model proposal status: ${data.status ?? "(unknown)"}.`,
|
|
11570
|
+
severity: "info"
|
|
11571
|
+
};
|
|
11572
|
+
}
|
|
11070
11573
|
function truncate(s, max) {
|
|
11071
11574
|
return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
|
|
11072
11575
|
}
|
|
@@ -11113,7 +11616,9 @@ var NOTIFICATION_TOPICS = [
|
|
|
11113
11616
|
// Hermes Phase 4 — skill proposal lifecycle.
|
|
11114
11617
|
"proposal.created",
|
|
11115
11618
|
"proposal.approved",
|
|
11116
|
-
"proposal.rejected"
|
|
11619
|
+
"proposal.rejected",
|
|
11620
|
+
// LMLM Phase 7 — model-proposal lifecycle (colon→dot ⇒ 'local-models.proposal').
|
|
11621
|
+
"local-models:proposal"
|
|
11117
11622
|
];
|
|
11118
11623
|
function newEventId3() {
|
|
11119
11624
|
return `evt_${(0, import_node_crypto14.randomBytes)(8).toString("hex")}`;
|
|
@@ -11169,7 +11674,7 @@ function wireNotificationSinks({ bus, registry }) {
|
|
|
11169
11674
|
}
|
|
11170
11675
|
|
|
11171
11676
|
// src/orchestrator.ts
|
|
11172
|
-
var
|
|
11677
|
+
var import_core18 = require("@harness-engineering/core");
|
|
11173
11678
|
|
|
11174
11679
|
// src/logging/logger.ts
|
|
11175
11680
|
var StructuredLogger = class {
|
|
@@ -13198,6 +13703,45 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13198
13703
|
* so this map is the single source of truth post-migration.
|
|
13199
13704
|
*/
|
|
13200
13705
|
localResolvers = /* @__PURE__ */ new Map();
|
|
13706
|
+
/** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
|
|
13707
|
+
poolStateProvider = null;
|
|
13708
|
+
poolStateStore = null;
|
|
13709
|
+
/**
|
|
13710
|
+
* LMLM Phase 6: live model pool + its installer. Constructed only when
|
|
13711
|
+
* `localModels.enabled` and a real `PoolStateStore` exists (not a test
|
|
13712
|
+
* override). Exposed to the server via `getModelPool()`, which retires the
|
|
13713
|
+
* proposals-route 501 stub for `kind: 'model'` approve/reject. Null when LMLM
|
|
13714
|
+
* is disabled. `PoolManager` reads `store.snapshot()` lazily, so constructing
|
|
13715
|
+
* before `store.load()` (in initLocalModelAndPipeline) is safe.
|
|
13716
|
+
*/
|
|
13717
|
+
modelPool = null;
|
|
13718
|
+
modelInstaller = null;
|
|
13719
|
+
/**
|
|
13720
|
+
* S1 drain re-entrancy guard (P7-SUG-DRAIN-REENTRANCY). `drainDeferredEvictions`
|
|
13721
|
+
* is fired fire-and-forget from `emitWorkerExit` (and, since P7-SUG-DRAIN-LIVENESS,
|
|
13722
|
+
* piggybacked on each refresh tick). Two overlapping drains would both read the
|
|
13723
|
+
* same `listPendingEvictions()` snapshot, both re-check `isLocalModelInUse`, and
|
|
13724
|
+
* both `await pool.evict` the SAME name — double-calling the installer and
|
|
13725
|
+
* broadcasting a duplicate `evict_completed` frame. The single-threaded event
|
|
13726
|
+
* loop makes a plain boolean sufficient: a drain that arrives while one is
|
|
13727
|
+
* running returns early rather than double-processing.
|
|
13728
|
+
*/
|
|
13729
|
+
draining = false;
|
|
13730
|
+
/**
|
|
13731
|
+
* LMLM Phase 6: single per-instance background refresh scheduler. Started in
|
|
13732
|
+
* `initLocalModelAndPipeline` when the pool exists; stopped in `stop()`. Null
|
|
13733
|
+
* when LMLM is disabled. Exposed to the server via `getRefreshScheduler()`.
|
|
13734
|
+
*/
|
|
13735
|
+
refreshScheduler = null;
|
|
13736
|
+
/**
|
|
13737
|
+
* LMLM Phase 7: the hardware-aware recommender bound at scheduler start. Reused
|
|
13738
|
+
* by `GET /api/v1/local-models/recommendations`. Null when LMLM is disabled (no
|
|
13739
|
+
* pool → scheduler never armed). Ranks the (currently empty) candidate set —
|
|
13740
|
+
* see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
|
|
13741
|
+
*/
|
|
13742
|
+
modelRecommender = null;
|
|
13743
|
+
/** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
|
|
13744
|
+
schedulerTimerOverride;
|
|
13201
13745
|
/**
|
|
13202
13746
|
* Spec B Phase 3: skill catalog (name + cognitiveMode) read once at
|
|
13203
13747
|
* construction from `projectRoot/agents/skills/`. Consulted by
|
|
@@ -13288,6 +13832,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13288
13832
|
*/
|
|
13289
13833
|
constructor(config, promptTemplate, overrides) {
|
|
13290
13834
|
super();
|
|
13835
|
+
this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
|
|
13291
13836
|
this.setMaxListeners(50);
|
|
13292
13837
|
this.config = config;
|
|
13293
13838
|
this.promptTemplate = promptTemplate;
|
|
@@ -13335,6 +13880,16 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13335
13880
|
this
|
|
13336
13881
|
);
|
|
13337
13882
|
this.analysisArchive = new AnalysisArchive(path21.join(config.workspace.root, "..", "analyses"));
|
|
13883
|
+
const localModelsEnabled = this.config.localModels?.enabled === true;
|
|
13884
|
+
if (overrides?.poolState) {
|
|
13885
|
+
this.poolStateProvider = overrides.poolState;
|
|
13886
|
+
} else if (localModelsEnabled) {
|
|
13887
|
+
this.poolStateStore = new import_local_models4.PoolStateStore({
|
|
13888
|
+
onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
|
|
13889
|
+
});
|
|
13890
|
+
this.poolStateProvider = this.poolStateStore;
|
|
13891
|
+
this.initModelPool(this.poolStateStore);
|
|
13892
|
+
}
|
|
13338
13893
|
const backendsMap = this.config.agent.backends ?? {};
|
|
13339
13894
|
for (const [name, def] of Object.entries(backendsMap)) {
|
|
13340
13895
|
if (def.type === "local" || def.type === "pi") {
|
|
@@ -13345,10 +13900,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13345
13900
|
};
|
|
13346
13901
|
if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
|
|
13347
13902
|
if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
|
|
13903
|
+
if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
|
|
13348
13904
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
13349
13905
|
}
|
|
13350
13906
|
}
|
|
13351
|
-
this.cacheMetrics = new
|
|
13907
|
+
this.cacheMetrics = new import_core18.CacheMetricsRecorder();
|
|
13352
13908
|
if (this.config.agent.backends !== void 0 && Object.keys(this.config.agent.backends).length > 0) {
|
|
13353
13909
|
const sandboxPolicy = this.config.agent.sandboxPolicy === "docker" ? "docker" : "none";
|
|
13354
13910
|
const firstBackendName = Object.keys(this.config.agent.backends)[0];
|
|
@@ -13456,7 +14012,26 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13456
14012
|
roadmapPath: config.tracker.filePath ?? null,
|
|
13457
14013
|
dispatchAdHoc: this.dispatchAdHoc.bind(this),
|
|
13458
14014
|
getLocalModelStatus: () => this.getFirstLocalModelStatus(),
|
|
13459
|
-
getLocalModelStatuses: () => this.buildLocalModelStatuses()
|
|
14015
|
+
getLocalModelStatuses: () => this.buildLocalModelStatuses(),
|
|
14016
|
+
// LMLM Phase 6: expose the live pool so kind:'model' approve/reject
|
|
14017
|
+
// reaches PoolManager (retiring the 501). Null when LMLM is disabled.
|
|
14018
|
+
getModelPool: () => this.modelPool,
|
|
14019
|
+
// LMLM Phase 7 / S1: conservative in-use probe so an approved swap/evict
|
|
14020
|
+
// of a model an agent could be using is DEFERRED, not applied mid-request
|
|
14021
|
+
// (ADR 0060). Agent-run-coarse; may over-defer (safe).
|
|
14022
|
+
isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
|
|
14023
|
+
// LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
|
|
14024
|
+
getRefreshScheduler: () => this.refreshScheduler,
|
|
14025
|
+
// LMLM Phase 7 read surface — hardware / recommendations / model proposals.
|
|
14026
|
+
// Each returns null/[] when LMLM is disabled so the route renders 503/[].
|
|
14027
|
+
getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
|
|
14028
|
+
getRecommendations: async ({ top }) => {
|
|
14029
|
+
if (this.modelRecommender === null) return [];
|
|
14030
|
+
const hardware = await this.detectLmlmHardware();
|
|
14031
|
+
const { ranked } = await this.modelRecommender(hardware);
|
|
14032
|
+
return ranked.slice(0, top);
|
|
14033
|
+
},
|
|
14034
|
+
listModelProposals: () => (0, import_core17.listProposals)(this.projectRoot, { status: "open", kind: "model" })
|
|
13460
14035
|
});
|
|
13461
14036
|
this.server.setRecorder(this.recorder);
|
|
13462
14037
|
this.interactionQueue.onPush((interaction) => {
|
|
@@ -13473,7 +14048,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13473
14048
|
setupTelemetryExport(config, webhookStore, webhookDelivery) {
|
|
13474
14049
|
const otlpCfg = config.telemetry?.export?.otlp;
|
|
13475
14050
|
if (!otlpCfg) return;
|
|
13476
|
-
this.otlpExporter = new
|
|
14051
|
+
this.otlpExporter = new import_core18.OTLPExporter({
|
|
13477
14052
|
endpoint: otlpCfg.endpoint,
|
|
13478
14053
|
...otlpCfg.enabled !== void 0 ? { enabled: otlpCfg.enabled } : {},
|
|
13479
14054
|
...otlpCfg.headers !== void 0 ? { headers: otlpCfg.headers } : {},
|
|
@@ -13514,6 +14089,60 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13514
14089
|
}
|
|
13515
14090
|
return out;
|
|
13516
14091
|
}
|
|
14092
|
+
/**
|
|
14093
|
+
* S1 conservative in-use probe (ADR 0060). Returns `true` when ANY agent run
|
|
14094
|
+
* is live AND `ollamaName` is a currently-resolved (or last-detected) local
|
|
14095
|
+
* model — i.e. an agent could be routing inference to it right now.
|
|
14096
|
+
*
|
|
14097
|
+
* This signal is AGENT-RUN-COARSE, not per-request: `state.running` is keyed
|
|
14098
|
+
* by GitHub issue (spawned agent runs), NOT by inference call, and no
|
|
14099
|
+
* per-model request counter exists today. The probe therefore MAY OVER-DEFER
|
|
14100
|
+
* (a swap waits until the pool is idle). Over-deferral is exactly S1's
|
|
14101
|
+
* intended safe failure — never yank a model mid-request; occasionally wait
|
|
14102
|
+
* longer than strictly necessary. A fine-grained per-request signal is an
|
|
14103
|
+
* explicit deferred gap (ADR 0060).
|
|
14104
|
+
*/
|
|
14105
|
+
isLocalModelInUse(ollamaName) {
|
|
14106
|
+
if (this.state.running.size === 0) return false;
|
|
14107
|
+
return this.buildLocalModelStatuses().some(
|
|
14108
|
+
(s) => s.resolved === ollamaName || s.detected.includes(ollamaName)
|
|
14109
|
+
);
|
|
14110
|
+
}
|
|
14111
|
+
/**
|
|
14112
|
+
* S1 drain (ADR 0060): complete any eviction that was DEFERRED because its
|
|
14113
|
+
* target was in use, now that the probe reports it idle. Best-effort — called
|
|
14114
|
+
* from the run-completion path; it never blocks dispatch and swallows
|
|
14115
|
+
* per-model errors (a failed or still-busy evict stays pending for the next
|
|
14116
|
+
* drain). `pendingEviction` is a transient overlay, so a missed drain simply
|
|
14117
|
+
* leaves the flag set until the next completion re-checks it.
|
|
14118
|
+
*/
|
|
14119
|
+
async drainDeferredEvictions() {
|
|
14120
|
+
const pool = this.modelPool;
|
|
14121
|
+
if (pool === null) return;
|
|
14122
|
+
if (this.draining) return;
|
|
14123
|
+
this.draining = true;
|
|
14124
|
+
try {
|
|
14125
|
+
for (const ollamaName of pool.listPendingEvictions()) {
|
|
14126
|
+
if (this.isLocalModelInUse(ollamaName)) continue;
|
|
14127
|
+
try {
|
|
14128
|
+
const result = await pool.evict({ ollamaName });
|
|
14129
|
+
if (result.status === "error") continue;
|
|
14130
|
+
pool.clearPendingEviction(ollamaName);
|
|
14131
|
+
this.emit("local-models:pool", {
|
|
14132
|
+
action: "evict",
|
|
14133
|
+
// XP-2: `evicted` is uniformly string[] across all local-models:pool
|
|
14134
|
+
// emit sites — the drain wraps its single completed eviction in an
|
|
14135
|
+
// array to match the swap/add multi-evict shape.
|
|
14136
|
+
evicted: [ollamaName],
|
|
14137
|
+
phase: "evict_completed"
|
|
14138
|
+
});
|
|
14139
|
+
} catch {
|
|
14140
|
+
}
|
|
14141
|
+
}
|
|
14142
|
+
} finally {
|
|
14143
|
+
this.draining = false;
|
|
14144
|
+
}
|
|
14145
|
+
}
|
|
13517
14146
|
createTracker() {
|
|
13518
14147
|
if (this.config.tracker.kind === "github-issues") {
|
|
13519
14148
|
const trackerCfg = {
|
|
@@ -14360,6 +14989,7 @@ ${messages}`);
|
|
|
14360
14989
|
error,
|
|
14361
14990
|
(effect) => this.handleEffect(effect)
|
|
14362
14991
|
);
|
|
14992
|
+
void this.drainDeferredEvictions();
|
|
14363
14993
|
this.emit("state_change", this.getSnapshot());
|
|
14364
14994
|
}
|
|
14365
14995
|
/**
|
|
@@ -14447,6 +15077,91 @@ ${messages}`);
|
|
|
14447
15077
|
* before constructing the intelligence pipeline. Subscribes the dashboard
|
|
14448
15078
|
* broadcast stub to status changes. Called exactly once from start().
|
|
14449
15079
|
*/
|
|
15080
|
+
/**
|
|
15081
|
+
* LMLM Phase 6: construct the live `PoolManager` (Ollama installer + the
|
|
15082
|
+
* loaded pool-state store) and stash it for `getModelPool()`. Defensive
|
|
15083
|
+
* config fallbacks: `ollamaEndpoint → http://localhost:11434`. The pool reads
|
|
15084
|
+
* `store.snapshot()` lazily, so this runs safely at construction time before
|
|
15085
|
+
* `store.load()`.
|
|
15086
|
+
*/
|
|
15087
|
+
initModelPool(store) {
|
|
15088
|
+
const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
|
|
15089
|
+
const installerCfg = this.config.localModels?.installer;
|
|
15090
|
+
this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
|
|
15091
|
+
baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
|
|
15092
|
+
onWarn
|
|
15093
|
+
});
|
|
15094
|
+
this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
|
|
15095
|
+
}
|
|
15096
|
+
/**
|
|
15097
|
+
* LMLM Phase 6: arm the single background refresh scheduler over the live
|
|
15098
|
+
* pool. No-op when LMLM is disabled (`modelPool` null). Each tick runs
|
|
15099
|
+
* hardware→recommend→reconcile(D12 drift)→diff→emit→score-writeback.
|
|
15100
|
+
*
|
|
15101
|
+
* NOTE (deferred): the recommender is seeded with an empty candidate set —
|
|
15102
|
+
* Phase 2's live-HF→RankerCandidate parser was never built, so autonomous
|
|
15103
|
+
* swap-proposal discovery is out of scope here (flagged concern). The tick
|
|
15104
|
+
* still performs F10 drift reconciliation, O1 logging, and re-ranks/dedups
|
|
15105
|
+
* whatever candidates are supplied — the wiring is complete and candidate
|
|
15106
|
+
* breadth is the only piece deferred to the Phase 2 recommender.
|
|
15107
|
+
*/
|
|
15108
|
+
startRefreshScheduler() {
|
|
15109
|
+
if (this.modelPool === null) return;
|
|
15110
|
+
const pool = this.modelPool;
|
|
15111
|
+
const refreshCfg = this.config.localModels?.refresh;
|
|
15112
|
+
const recommend = (0, import_local_models4.createNativeRecommender)({ candidates: [] });
|
|
15113
|
+
this.modelRecommender = recommend;
|
|
15114
|
+
this.refreshScheduler = new import_local_models4.RefreshScheduler({
|
|
15115
|
+
runTick: () => (0, import_local_models4.runRefreshTick)({
|
|
15116
|
+
detectHardware: () => this.detectLmlmHardware(),
|
|
15117
|
+
recommend,
|
|
15118
|
+
poolManager: pool,
|
|
15119
|
+
dedupSource: () => this.lmlmDedupSource(),
|
|
15120
|
+
// Phase 7: after persisting the proposal, emit `local-models:proposal`
|
|
15121
|
+
// (== MODEL_PROPOSAL_TOPIC) on the bus so it fans out to WS clients and
|
|
15122
|
+
// notification sinks. Literal to avoid a proposals/model-handlers cycle.
|
|
15123
|
+
emitProposal: (c) => (0, import_core17.createModelProposal)(this.projectRoot, c).then((record) => {
|
|
15124
|
+
this.emit("local-models:proposal", {
|
|
15125
|
+
id: record.id,
|
|
15126
|
+
status: "created",
|
|
15127
|
+
action: c.action,
|
|
15128
|
+
target: c.target.ollamaName
|
|
15129
|
+
});
|
|
15130
|
+
}),
|
|
15131
|
+
proposalThreshold: refreshCfg?.proposalThreshold ?? 5
|
|
15132
|
+
}).then((result) => {
|
|
15133
|
+
void this.drainDeferredEvictions();
|
|
15134
|
+
return result;
|
|
15135
|
+
}),
|
|
15136
|
+
intervalMs: refreshCfg?.intervalMs ?? 864e5,
|
|
15137
|
+
jitterMs: refreshCfg?.jitterMs ?? 6e5,
|
|
15138
|
+
logger: this.logger,
|
|
15139
|
+
...this.schedulerTimerOverride ?? {}
|
|
15140
|
+
});
|
|
15141
|
+
this.refreshScheduler.start();
|
|
15142
|
+
}
|
|
15143
|
+
/** Resolve the hardware profile for a refresh tick (operator override wins). */
|
|
15144
|
+
async detectLmlmHardware() {
|
|
15145
|
+
const override = this.config.localModels?.hardware?.override;
|
|
15146
|
+
const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
|
|
15147
|
+
return (await detector.detect()).profile;
|
|
15148
|
+
}
|
|
15149
|
+
/** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
|
|
15150
|
+
async lmlmDedupSource() {
|
|
15151
|
+
const proposals = await (0, import_core17.listProposals)(this.projectRoot, { kind: "model" });
|
|
15152
|
+
const pending = [];
|
|
15153
|
+
const rejected = [];
|
|
15154
|
+
for (const p of proposals) {
|
|
15155
|
+
if (p.kind !== "model") continue;
|
|
15156
|
+
const pair = {
|
|
15157
|
+
target: p.model.target.ollamaName,
|
|
15158
|
+
...p.model.replaces ? { replaces: p.model.replaces.ollamaName } : {}
|
|
15159
|
+
};
|
|
15160
|
+
if (p.status === "open") pending.push(pair);
|
|
15161
|
+
else if (p.status === "rejected") rejected.push(pair);
|
|
15162
|
+
}
|
|
15163
|
+
return { pending, rejected };
|
|
15164
|
+
}
|
|
14450
15165
|
async initLocalModelAndPipeline() {
|
|
14451
15166
|
if (this.localResolvers.size > 0) {
|
|
14452
15167
|
const backends = this.config.agent.backends ?? {};
|
|
@@ -14469,10 +15184,14 @@ ${messages}`);
|
|
|
14469
15184
|
});
|
|
14470
15185
|
this.localModelStatusUnsubscribes.push(unsubscribe);
|
|
14471
15186
|
}
|
|
15187
|
+
if (this.poolStateStore !== null) {
|
|
15188
|
+
await this.poolStateStore.load();
|
|
15189
|
+
}
|
|
14472
15190
|
for (const resolver of this.localResolvers.values()) {
|
|
14473
15191
|
await resolver.start();
|
|
14474
15192
|
}
|
|
14475
15193
|
}
|
|
15194
|
+
this.startRefreshScheduler();
|
|
14476
15195
|
this.pipeline = this.createIntelligencePipeline();
|
|
14477
15196
|
this.server?.setPipeline(this.pipeline);
|
|
14478
15197
|
}
|
|
@@ -14548,6 +15267,8 @@ ${messages}`);
|
|
|
14548
15267
|
for (const resolver of this.localResolvers.values()) {
|
|
14549
15268
|
resolver.stop();
|
|
14550
15269
|
}
|
|
15270
|
+
this.refreshScheduler?.stop();
|
|
15271
|
+
this.refreshScheduler = null;
|
|
14551
15272
|
if (this.maintenanceScheduler) {
|
|
14552
15273
|
this.maintenanceScheduler.stop();
|
|
14553
15274
|
this.maintenanceScheduler = null;
|