@mrrlin-dev/mcp 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/bin.cjs +429 -38
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ The bridge auto-picks up new `@mrrlin-dev/mcp` versions after `npm install -g`.
|
|
|
10
10
|
|
|
11
11
|
- **L1 (self-update loop):** every 15 minutes (env-tunable) the bridge hashes its own `bin.cjs` and, on change, drains the current Codex turn cleanly and exits via `bridgeShutdown`. PM2 restarts. New code loads.
|
|
12
12
|
- **L2 (postinstall nudge):** `npm install -g @mrrlin-dev/mcp@latest` triggers a `SIGUSR2` to every running PM2 bridge, collapsing the L1 wait to seconds.
|
|
13
|
-
- **L3 (in-chat update offer):** every 6 hours the bridge checks `https://registry.npmjs.org/@mrrlin-dev/mcp/latest`. When a newer version is published, on your next chat turn the Director
|
|
13
|
+
- **L3 (in-chat update offer):** every 6 hours the bridge checks `https://registry.npmjs.org/@mrrlin-dev/mcp/latest`. When a newer version is published, on your next chat turn the Director explains *why* to update (latest fixes/improvements, changelog link) and offers a choice — let it run the update, or run `npm install -g @mrrlin-dev/mcp@latest` yourself. Say "yes" → Director runs the install itself. 48h cooldown if you decline.
|
|
14
|
+
- **L3.3 (inbox mirror):** the same fire also files a persistent `question` in the active project's inbox ("A newer version of the mrrlin bridge is available…", with the changelog link), so the update isn't only an ephemeral per-turn aside. The body is version-agnostic on purpose: the inbox dedup collapses it to one open row per project (reused across versions), and it re-appears at most once per 48h cooldown after you decide it. It's an opportunistic per-project signal, not a guaranteed cross-project notice.
|
|
14
15
|
- **L4 (process hygiene):** the bridge refuses to start a second instance on the same port and warns about orphan `mrrlin-mcp director-bridge` processes at startup.
|
|
15
16
|
|
|
16
17
|
### Env tuning
|
package/dist/bin.cjs
CHANGED
|
@@ -20799,9 +20799,8 @@ function renderBlock(block) {
|
|
|
20799
20799
|
lines2.push("");
|
|
20800
20800
|
return lines2.join("\n");
|
|
20801
20801
|
}
|
|
20802
|
-
|
|
20803
|
-
|
|
20804
|
-
const candidate = actual;
|
|
20802
|
+
var KNOWN_MRRLIN_ENV_KEYS = /* @__PURE__ */ new Set(["MRRLIN_STAGING", "MRRLIN_API_BASE_URL", "MRRLIN_API_ALLOW_REMOTE"]);
|
|
20803
|
+
function commandArgsTimeoutEqual(candidate, expected) {
|
|
20805
20804
|
if (candidate.command !== expected.command) return false;
|
|
20806
20805
|
if (!Array.isArray(candidate.args)) return false;
|
|
20807
20806
|
if (candidate.args.length !== expected.args.length) return false;
|
|
@@ -20811,6 +20810,12 @@ function tablesEqual(actual, expected) {
|
|
|
20811
20810
|
if (candidate.startup_timeout_sec !== void 0 && candidate.startup_timeout_sec !== expected.startup_timeout_sec) {
|
|
20812
20811
|
return false;
|
|
20813
20812
|
}
|
|
20813
|
+
return true;
|
|
20814
|
+
}
|
|
20815
|
+
function tablesEqual(actual, expected) {
|
|
20816
|
+
if (typeof actual !== "object" || actual === null) return false;
|
|
20817
|
+
const candidate = actual;
|
|
20818
|
+
if (!commandArgsTimeoutEqual(candidate, expected)) return false;
|
|
20814
20819
|
const expectedEnv = expected.env ?? {};
|
|
20815
20820
|
const actualEnv = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
|
|
20816
20821
|
const expectedKeys = Object.keys(expectedEnv);
|
|
@@ -20820,6 +20825,34 @@ function tablesEqual(actual, expected) {
|
|
|
20820
20825
|
}
|
|
20821
20826
|
return true;
|
|
20822
20827
|
}
|
|
20828
|
+
function isCoherentMrrlinEnv(env) {
|
|
20829
|
+
const keys = Object.keys(env);
|
|
20830
|
+
const staging = env.MRRLIN_STAGING;
|
|
20831
|
+
const url2 = env.MRRLIN_API_BASE_URL;
|
|
20832
|
+
const allowRemote = env.MRRLIN_API_ALLOW_REMOTE;
|
|
20833
|
+
const hasUrl = typeof url2 === "string" && url2.trim() !== "";
|
|
20834
|
+
if (keys.length === 1 && staging === "1") return true;
|
|
20835
|
+
if (keys.length === 1 && hasUrl) return true;
|
|
20836
|
+
if (keys.length === 2 && hasUrl && allowRemote === "1") return true;
|
|
20837
|
+
return false;
|
|
20838
|
+
}
|
|
20839
|
+
function classifyExistingBlock(actual, expected) {
|
|
20840
|
+
if (tablesEqual(actual, expected)) return "equal";
|
|
20841
|
+
if (typeof actual !== "object" || actual === null) return "conflict";
|
|
20842
|
+
const candidate = actual;
|
|
20843
|
+
if (expected.env && Object.keys(expected.env).length > 0) return "conflict";
|
|
20844
|
+
if (!commandArgsTimeoutEqual(candidate, expected)) return "conflict";
|
|
20845
|
+
const env = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
|
|
20846
|
+
const keys = Object.keys(env);
|
|
20847
|
+
if (keys.length === 0) return "conflict";
|
|
20848
|
+
if (!keys.every((key) => KNOWN_MRRLIN_ENV_KEYS.has(key))) return "conflict";
|
|
20849
|
+
if (!isCoherentMrrlinEnv(env)) return "conflict";
|
|
20850
|
+
return "preserve-env-noop";
|
|
20851
|
+
}
|
|
20852
|
+
function renderEnvPreservedNotice(blockId, envKeys) {
|
|
20853
|
+
const keys = [...envKeys].sort().join(", ");
|
|
20854
|
+
return `[mrrlin-mcp install-codex] kept your existing [mcp_servers.${blockId}] env keys (${keys}) so your API target is preserved. Re-run with --force to replace or remove it.`;
|
|
20855
|
+
}
|
|
20823
20856
|
async function pathExists(target) {
|
|
20824
20857
|
try {
|
|
20825
20858
|
await import_promises.default.access(target);
|
|
@@ -20845,7 +20878,7 @@ async function installCodex(options) {
|
|
|
20845
20878
|
if (!await pathExists(configPath)) {
|
|
20846
20879
|
const content2 = blocks.map(renderBlock).join("").replace(/^\n/, "");
|
|
20847
20880
|
await import_promises.default.writeFile(configPath, content2, { mode: 384 });
|
|
20848
|
-
return { action: "created", configPath, prompts: await writePrompts() };
|
|
20881
|
+
return { action: "created", configPath, prompts: await writePrompts(), preservedEnvBlocks: [] };
|
|
20849
20882
|
}
|
|
20850
20883
|
const realPath = await resolveRealConfigPath(configPath);
|
|
20851
20884
|
let content = await import_promises.default.readFile(realPath, "utf8");
|
|
@@ -20862,10 +20895,19 @@ async function installCodex(options) {
|
|
|
20862
20895
|
throw Object.assign(new Error(err.detail), err);
|
|
20863
20896
|
}
|
|
20864
20897
|
let action = "noop";
|
|
20898
|
+
let mutated = false;
|
|
20899
|
+
const preservedEnvBlocks = [];
|
|
20865
20900
|
for (const block of blocks) {
|
|
20866
20901
|
const existingTable = parsed?.mcp_servers?.[block.id];
|
|
20867
20902
|
if (existingTable !== void 0) {
|
|
20868
|
-
|
|
20903
|
+
const classification = classifyExistingBlock(existingTable, block.expected);
|
|
20904
|
+
if (classification === "equal") continue;
|
|
20905
|
+
if (classification === "preserve-env-noop") {
|
|
20906
|
+
const env = existingTable.env ?? {};
|
|
20907
|
+
preservedEnvBlocks.push({ blockId: block.id, envKeys: Object.keys(env).sort() });
|
|
20908
|
+
if (action === "noop") action = "env-preserved";
|
|
20909
|
+
continue;
|
|
20910
|
+
}
|
|
20869
20911
|
if (!options.force) {
|
|
20870
20912
|
const err = {
|
|
20871
20913
|
code: "CODEX_CONFIG_CONFLICT",
|
|
@@ -20876,15 +20918,17 @@ async function installCodex(options) {
|
|
|
20876
20918
|
}
|
|
20877
20919
|
content = replaceBlock(content, block.blockRe, renderBlock(block));
|
|
20878
20920
|
action = "replaced";
|
|
20921
|
+
mutated = true;
|
|
20879
20922
|
} else {
|
|
20880
20923
|
content = appendBlock(content, renderBlock(block));
|
|
20881
20924
|
if (action !== "replaced") action = "appended";
|
|
20925
|
+
mutated = true;
|
|
20882
20926
|
}
|
|
20883
20927
|
}
|
|
20884
|
-
if (
|
|
20928
|
+
if (mutated) {
|
|
20885
20929
|
await import_promises.default.writeFile(realPath, content, { mode: 384 });
|
|
20886
20930
|
}
|
|
20887
|
-
return { action, configPath: realPath, prompts: await writePrompts() };
|
|
20931
|
+
return { action, configPath: realPath, prompts: await writePrompts(), preservedEnvBlocks };
|
|
20888
20932
|
}
|
|
20889
20933
|
async function installPrompts(codexHome, prompts, forcePrompts) {
|
|
20890
20934
|
if (prompts.length === 0) return [];
|
|
@@ -38336,6 +38380,7 @@ var DEFAULT_STARTUP_JITTER_MS = 30 * 1e3;
|
|
|
38336
38380
|
var DEFAULT_COOLDOWN_MS = 48 * 60 * 60 * 1e3;
|
|
38337
38381
|
var REGISTRY_URL_DEFAULT = "https://registry.npmjs.org/@mrrlin-dev/mcp/latest";
|
|
38338
38382
|
var STRICT_SEMVER = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?(\+[A-Za-z0-9.-]+)?$/;
|
|
38383
|
+
var UPDATE_INBOX_BODY = "A newer version of the mrrlin bridge is available. Updating pulls the latest fixes and improvements. Changelog: https://mrrlin-changelog.s3.eu-central-1.amazonaws.com/index.html . Ask the Director to update, or run `npm install -g @mrrlin-dev/mcp@latest` yourself.";
|
|
38339
38384
|
function isDisabled() {
|
|
38340
38385
|
return process.env.MRRLIN_DIRECTOR_BRIDGE_SELF_UPDATE === "0";
|
|
38341
38386
|
}
|
|
@@ -38382,9 +38427,15 @@ function startSelfUpdate(deps) {
|
|
|
38382
38427
|
});
|
|
38383
38428
|
return `[bridge:update] An update for @mrrlin-dev/mcp is available: ${pkgVersion} -> ${latestVersion}.
|
|
38384
38429
|
|
|
38385
|
-
This turn, at a natural pause,
|
|
38430
|
+
This turn, at a natural pause, RAISE this with the operator IN THEIR LANGUAGE (mirror the language they are chatting in). Frame it as a short offer with a choice, and ALWAYS explain WHY it is worth doing:
|
|
38386
38431
|
|
|
38387
|
-
|
|
38432
|
+
1. WHY (never skip this): give a one-line reason the update matters - do NOT present a bare version bump. Prefer specifics: if you can read the release notes, summarize in one short sentence what is new/fixed. The changelog is at https://mrrlin-changelog.s3.eu-central-1.amazonaws.com/index.html. If you cannot get specifics, fall back to a generic but honest reason, e.g. "it pulls the latest bug fixes and improvements for the bridge".
|
|
38433
|
+
|
|
38434
|
+
2. THE CHOICE: offer both paths and let the operator pick - "want me to update it for you now?" OR "you can run it yourself: \`npm install -g @mrrlin-dev/mcp@latest\`". Phrase it as a yes/no, e.g. in English: "The bridge is on ${pkgVersion} but npm already has ${latestVersion}. It brings <why> - want me to run \`npm install -g @mrrlin-dev/mcp@latest\`, or would you rather run it yourself?".
|
|
38435
|
+
|
|
38436
|
+
ASK the operator first - do NOT install without explicit confirmation.
|
|
38437
|
+
|
|
38438
|
+
If the operator confirms you should do it: immediately run \`npm install -g @mrrlin-dev/mcp@latest\` via your shell tool and report the outcome in chat. After a successful install, the bridge auto-restarts via its self-update loop within a couple of minutes - you do NOT need to call pm2.
|
|
38388
38439
|
|
|
38389
38440
|
If the operator declines / says "later": acknowledge briefly and do not bring it up again this turn. The bridge has a cooldown and will re-offer in 48h.
|
|
38390
38441
|
|
|
@@ -38392,6 +38443,12 @@ If the install fails (permission denied, registry pinned, sudo required, network
|
|
|
38392
38443
|
|
|
38393
38444
|
Do NOT derail the current task - if the operator has an in-flight question, answer that first; the update offer is a brief aside, not the main subject.`;
|
|
38394
38445
|
}
|
|
38446
|
+
function updateInboxBody() {
|
|
38447
|
+
if (disabled) return null;
|
|
38448
|
+
if (latestVersion === null) return null;
|
|
38449
|
+
if (!import_semver.default.gt(latestVersion, pkgVersion)) return null;
|
|
38450
|
+
return UPDATE_INBOX_BODY;
|
|
38451
|
+
}
|
|
38395
38452
|
let startupBinSha = null;
|
|
38396
38453
|
try {
|
|
38397
38454
|
startupBinSha = sha256(readFile2(deps.binPath));
|
|
@@ -38545,12 +38602,13 @@ Do NOT derail the current task - if the operator has an in-flight question, answ
|
|
|
38545
38602
|
getDrainOnQuiet: () => drainOnQuiet,
|
|
38546
38603
|
getLatestVersion: () => latestVersion,
|
|
38547
38604
|
isStale: () => latestVersion !== null && import_semver.default.gt(latestVersion, pkgVersion),
|
|
38548
|
-
maybeUpdateOffer
|
|
38605
|
+
maybeUpdateOffer,
|
|
38606
|
+
updateInboxBody
|
|
38549
38607
|
};
|
|
38550
38608
|
}
|
|
38551
38609
|
|
|
38552
38610
|
// src/_generated/version.ts
|
|
38553
|
-
var PKG_VERSION = "0.3.
|
|
38611
|
+
var PKG_VERSION = "0.3.15";
|
|
38554
38612
|
|
|
38555
38613
|
// src/api-base-url.ts
|
|
38556
38614
|
var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
|
|
@@ -38764,10 +38822,42 @@ function withTimeout(promise2, ms, message) {
|
|
|
38764
38822
|
});
|
|
38765
38823
|
}
|
|
38766
38824
|
async function awaitTurnWithDeadline(opts) {
|
|
38825
|
+
const hasIdle = opts.idleTimeoutMs != null && opts.idleTimeoutMessage != null;
|
|
38826
|
+
const overallDeadlineAt = Date.now() + opts.timeoutMs;
|
|
38827
|
+
let overallTimer = null;
|
|
38828
|
+
let idleTimer = null;
|
|
38829
|
+
let settled = false;
|
|
38830
|
+
const clearTimers = () => {
|
|
38831
|
+
settled = true;
|
|
38832
|
+
if (overallTimer) clearTimeout(overallTimer);
|
|
38833
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
38834
|
+
overallTimer = null;
|
|
38835
|
+
idleTimer = null;
|
|
38836
|
+
};
|
|
38837
|
+
const activity = { touch: () => {
|
|
38838
|
+
} };
|
|
38839
|
+
const idleTimeoutMs = opts.idleTimeoutMs;
|
|
38840
|
+
const idleTimeoutMessage = opts.idleTimeoutMessage;
|
|
38841
|
+
const deadline = new Promise((_, reject) => {
|
|
38842
|
+
overallTimer = setTimeout(() => reject(new Error(opts.timeoutMessage)), opts.timeoutMs);
|
|
38843
|
+
if (idleTimeoutMs != null && idleTimeoutMessage != null) {
|
|
38844
|
+
const armIdle = () => {
|
|
38845
|
+
if (settled) return;
|
|
38846
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
38847
|
+
idleTimer = setTimeout(() => {
|
|
38848
|
+
reject(new Error(Date.now() >= overallDeadlineAt ? opts.timeoutMessage : idleTimeoutMessage));
|
|
38849
|
+
}, idleTimeoutMs);
|
|
38850
|
+
};
|
|
38851
|
+
activity.touch = armIdle;
|
|
38852
|
+
armIdle();
|
|
38853
|
+
}
|
|
38854
|
+
});
|
|
38767
38855
|
try {
|
|
38768
|
-
await
|
|
38856
|
+
await Promise.race([opts.run(activity), deadline]);
|
|
38769
38857
|
} catch (error51) {
|
|
38770
|
-
|
|
38858
|
+
clearTimers();
|
|
38859
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
38860
|
+
const isDeadline = message === opts.timeoutMessage || hasIdle && message === opts.idleTimeoutMessage;
|
|
38771
38861
|
if (isDeadline) {
|
|
38772
38862
|
try {
|
|
38773
38863
|
await withTimeout(
|
|
@@ -38781,6 +38871,7 @@ async function awaitTurnWithDeadline(opts) {
|
|
|
38781
38871
|
}
|
|
38782
38872
|
throw error51;
|
|
38783
38873
|
}
|
|
38874
|
+
clearTimers();
|
|
38784
38875
|
}
|
|
38785
38876
|
|
|
38786
38877
|
// src/worktree.ts
|
|
@@ -38973,16 +39064,41 @@ ${lines2.join("\n")}`;
|
|
|
38973
39064
|
var DEFAULT_STALE_SECONDS = 300;
|
|
38974
39065
|
var DEFAULT_TOUCH_INTERVAL_MS = 6e4;
|
|
38975
39066
|
var DEFAULT_CLAIM_INTERVAL_SECONDS = 30;
|
|
38976
|
-
var
|
|
39067
|
+
var DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS = 600;
|
|
39068
|
+
var DEFAULT_EXEC_RUN_MAX_SECONDS = 3600;
|
|
38977
39069
|
var DEFAULT_CONTEXT_MAX_ARTIFACTS = 50;
|
|
39070
|
+
var EXECUTION_TURN_TIMEOUT_PREFIX = "Execution turn did not complete within";
|
|
39071
|
+
var EXECUTION_TURN_STALL_PREFIX = "Execution turn stalled: no Codex activity within";
|
|
39072
|
+
var EXECUTION_TURN_BUDGET_PREFIX = "Execution turn exceeded the run wall-clock budget";
|
|
39073
|
+
var EXECUTION_TURN_TIMEOUT_RETRY_CODE = "execution_turn_timeout_retry";
|
|
39074
|
+
var EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY = "Execution turn timed out; requeued for one automatic retry.";
|
|
39075
|
+
var EXECUTION_TURN_TIMEOUT_RETRY_CURSOR = "execution_turn_timeout_retry:1";
|
|
38978
39076
|
function readPositiveIntEnv(name, fallback) {
|
|
38979
39077
|
const raw = (process.env[name] ?? "").trim();
|
|
38980
39078
|
if (!raw) return fallback;
|
|
38981
39079
|
const parsed = Number.parseInt(raw, 10);
|
|
38982
39080
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
38983
39081
|
}
|
|
38984
|
-
function
|
|
38985
|
-
return readPositiveIntEnv("
|
|
39082
|
+
function execTurnIdleTimeoutMs() {
|
|
39083
|
+
return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS) * 1e3;
|
|
39084
|
+
}
|
|
39085
|
+
function execRunMaxMs() {
|
|
39086
|
+
return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS", DEFAULT_EXEC_RUN_MAX_SECONDS) * 1e3;
|
|
39087
|
+
}
|
|
39088
|
+
function explicitLegacyTurnTimeoutMs() {
|
|
39089
|
+
const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS ?? "").trim();
|
|
39090
|
+
if (!raw) return null;
|
|
39091
|
+
const parsed = Number.parseInt(raw, 10);
|
|
39092
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed * 1e3 : null;
|
|
39093
|
+
}
|
|
39094
|
+
function executionTurnStallMessage(idleMs) {
|
|
39095
|
+
return `${EXECUTION_TURN_STALL_PREFIX} ${idleMs}ms.`;
|
|
39096
|
+
}
|
|
39097
|
+
function executionTurnBudgetMessage(budgetMs) {
|
|
39098
|
+
return `${EXECUTION_TURN_BUDGET_PREFIX} (${budgetMs}ms).`;
|
|
39099
|
+
}
|
|
39100
|
+
function isExecutionTurnTimeoutReason(reason) {
|
|
39101
|
+
return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX) || reason.startsWith(EXECUTION_TURN_STALL_PREFIX);
|
|
38986
39102
|
}
|
|
38987
39103
|
function contextMaxArtifacts() {
|
|
38988
39104
|
return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS", DEFAULT_CONTEXT_MAX_ARTIFACTS);
|
|
@@ -39135,11 +39251,15 @@ async function runHasLease(deps, projectSlug, runId, leaseId) {
|
|
|
39135
39251
|
return false;
|
|
39136
39252
|
}
|
|
39137
39253
|
}
|
|
39138
|
-
async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText) {
|
|
39254
|
+
async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText, overallMs, phase) {
|
|
39139
39255
|
const mapper = new CodexBridgeEventMapper();
|
|
39140
39256
|
mapper.reset();
|
|
39141
39257
|
let turnError = null;
|
|
39142
39258
|
let interruptFailed = false;
|
|
39259
|
+
let touch = () => {
|
|
39260
|
+
};
|
|
39261
|
+
const turnStartedAt = Date.now();
|
|
39262
|
+
let lastActivityAt = turnStartedAt;
|
|
39143
39263
|
const persist = async (event) => {
|
|
39144
39264
|
if (event.type === "error") turnError = event.content;
|
|
39145
39265
|
const kind = artifactKindForEvent(event);
|
|
@@ -39161,6 +39281,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
|
|
|
39161
39281
|
});
|
|
39162
39282
|
let acceptedTurnId = null;
|
|
39163
39283
|
const unsub = codex.onNotification((method, params) => {
|
|
39284
|
+
const notifThreadId = params?.threadId;
|
|
39285
|
+
if (!notifThreadId || notifThreadId === threadId) {
|
|
39286
|
+
lastActivityAt = Date.now();
|
|
39287
|
+
touch();
|
|
39288
|
+
}
|
|
39164
39289
|
const events = mapper.mapNotification(
|
|
39165
39290
|
{ method, params },
|
|
39166
39291
|
threadId
|
|
@@ -39168,9 +39293,8 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
|
|
|
39168
39293
|
for (const event of events) pending.push(persist(event));
|
|
39169
39294
|
if (method === "turn/completed") {
|
|
39170
39295
|
const p = params;
|
|
39171
|
-
const notifThreadId = p?.threadId;
|
|
39172
39296
|
const notifTurnId = p?.turn?.id;
|
|
39173
|
-
const threadMatches = !
|
|
39297
|
+
const threadMatches = !p?.threadId || p.threadId === threadId;
|
|
39174
39298
|
const turnMatches = !acceptedTurnId || !notifTurnId || notifTurnId === acceptedTurnId;
|
|
39175
39299
|
if (threadMatches && turnMatches) resolveTurnEnded?.();
|
|
39176
39300
|
}
|
|
@@ -39180,10 +39304,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
|
|
|
39180
39304
|
});
|
|
39181
39305
|
}, deps.touchIntervalMs);
|
|
39182
39306
|
touchTimer.unref?.();
|
|
39307
|
+
const idleMs = execTurnIdleTimeoutMs();
|
|
39183
39308
|
try {
|
|
39184
|
-
const timeoutMs = execTurnTimeoutMs();
|
|
39185
39309
|
await awaitTurnWithDeadline({
|
|
39186
|
-
run: async () => {
|
|
39310
|
+
run: async (activity) => {
|
|
39311
|
+
touch = activity.touch;
|
|
39187
39312
|
const accepted = await codex.turn.start({
|
|
39188
39313
|
threadId,
|
|
39189
39314
|
input: [{ type: "text", text: inputText }]
|
|
@@ -39192,8 +39317,10 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
|
|
|
39192
39317
|
await turnEnded;
|
|
39193
39318
|
},
|
|
39194
39319
|
interrupt: () => codex.turn.interrupt({ threadId }),
|
|
39195
|
-
timeoutMs,
|
|
39196
|
-
timeoutMessage:
|
|
39320
|
+
timeoutMs: overallMs,
|
|
39321
|
+
timeoutMessage: executionTurnBudgetMessage(overallMs),
|
|
39322
|
+
idleTimeoutMs: idleMs,
|
|
39323
|
+
idleTimeoutMessage: executionTurnStallMessage(idleMs),
|
|
39197
39324
|
onInterruptFailed: () => {
|
|
39198
39325
|
interruptFailed = true;
|
|
39199
39326
|
}
|
|
@@ -39202,6 +39329,19 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
|
|
|
39202
39329
|
await Promise.all(pending);
|
|
39203
39330
|
} catch (error51) {
|
|
39204
39331
|
turnError = error51 instanceof Error ? error51.message : String(error51);
|
|
39332
|
+
if (turnError.startsWith(EXECUTION_TURN_STALL_PREFIX) || turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX)) {
|
|
39333
|
+
console.warn(
|
|
39334
|
+
JSON.stringify({
|
|
39335
|
+
event: "execution_turn_deadline",
|
|
39336
|
+
projectSlug,
|
|
39337
|
+
runId,
|
|
39338
|
+
phase,
|
|
39339
|
+
kind: turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX) ? "budget" : "stall",
|
|
39340
|
+
elapsedMs: Date.now() - turnStartedAt,
|
|
39341
|
+
sinceLastActivityMs: Date.now() - lastActivityAt
|
|
39342
|
+
})
|
|
39343
|
+
);
|
|
39344
|
+
}
|
|
39205
39345
|
} finally {
|
|
39206
39346
|
clearInterval(touchTimer);
|
|
39207
39347
|
unsub();
|
|
@@ -39223,6 +39363,12 @@ async function driveRun(deps, projectSlug, runId) {
|
|
|
39223
39363
|
return;
|
|
39224
39364
|
}
|
|
39225
39365
|
const leaseId = claimed.leaseId;
|
|
39366
|
+
const attemptDeadlineMs = Date.now() + execRunMaxMs();
|
|
39367
|
+
const remainingTurnBudgetMs = () => {
|
|
39368
|
+
const remaining = attemptDeadlineMs - Date.now();
|
|
39369
|
+
const legacy = explicitLegacyTurnTimeoutMs();
|
|
39370
|
+
return legacy != null ? Math.min(legacy, remaining) : remaining;
|
|
39371
|
+
};
|
|
39226
39372
|
const task = await deps.client.getTask(projectSlug, claimed.taskId);
|
|
39227
39373
|
const compactEnabled = (process.env.MRRLIN_MCP_COMPACT_DISABLE ?? "").trim() !== "1";
|
|
39228
39374
|
const context = await gatherContext(deps.client, projectSlug, task, runId);
|
|
@@ -39260,6 +39406,7 @@ async function driveRun(deps, projectSlug, runId) {
|
|
|
39260
39406
|
runId,
|
|
39261
39407
|
defaultBranch: await getDefaultBranch(projectSlug)
|
|
39262
39408
|
});
|
|
39409
|
+
if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
|
|
39263
39410
|
await deps.client.updateExecutionRun(projectSlug, runId, {
|
|
39264
39411
|
worktreeId: worktreePath,
|
|
39265
39412
|
branch: worktreeBranch(runId)
|
|
@@ -39310,11 +39457,17 @@ async function driveRun(deps, projectSlug, runId) {
|
|
|
39310
39457
|
ephemeral: null
|
|
39311
39458
|
},
|
|
39312
39459
|
onThreadIdChanged: async (newId) => {
|
|
39460
|
+
if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
|
|
39313
39461
|
await deps.client.updateTask(projectSlug, task.id, { directorThreadId: newId });
|
|
39314
39462
|
}
|
|
39315
39463
|
});
|
|
39316
39464
|
if (!resumed) {
|
|
39317
|
-
const
|
|
39465
|
+
const primingBudgetMs = remainingTurnBudgetMs();
|
|
39466
|
+
if (primingBudgetMs <= 0) {
|
|
39467
|
+
await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
|
|
39468
|
+
return;
|
|
39469
|
+
}
|
|
39470
|
+
const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle, primingBudgetMs, "priming");
|
|
39318
39471
|
if (!primed.ok) {
|
|
39319
39472
|
await resetWedgedThread(deps, projectSlug, task.id, primed.interruptFailed);
|
|
39320
39473
|
await failRun(deps, projectSlug, task, runId, leaseId, primed.error);
|
|
@@ -39324,7 +39477,12 @@ async function driveRun(deps, projectSlug, runId) {
|
|
|
39324
39477
|
const executionPrompt = resumed ? `${bundle}
|
|
39325
39478
|
|
|
39326
39479
|
Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.` : `Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.`;
|
|
39327
|
-
const
|
|
39480
|
+
const workBudgetMs = remainingTurnBudgetMs();
|
|
39481
|
+
if (workBudgetMs <= 0) {
|
|
39482
|
+
await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
|
|
39483
|
+
return;
|
|
39484
|
+
}
|
|
39485
|
+
const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt, workBudgetMs, "work");
|
|
39328
39486
|
if (!outcome.ok) {
|
|
39329
39487
|
await resetWedgedThread(deps, projectSlug, task.id, outcome.interruptFailed);
|
|
39330
39488
|
await failRun(deps, projectSlug, task, runId, leaseId, outcome.error);
|
|
@@ -39452,7 +39610,36 @@ async function resetWedgedThread(deps, projectSlug, taskId, interruptFailed) {
|
|
|
39452
39610
|
});
|
|
39453
39611
|
}
|
|
39454
39612
|
async function failRun(deps, projectSlug, task, runId, leaseId, reason) {
|
|
39455
|
-
|
|
39613
|
+
let currentRun;
|
|
39614
|
+
try {
|
|
39615
|
+
currentRun = await deps.client.getExecutionRun(projectSlug, runId);
|
|
39616
|
+
} catch {
|
|
39617
|
+
return;
|
|
39618
|
+
}
|
|
39619
|
+
if (currentRun.status !== "running" || currentRun.leaseId !== leaseId) return;
|
|
39620
|
+
if (isExecutionTurnTimeoutReason(reason) && currentRun.checkpointCursor !== EXECUTION_TURN_TIMEOUT_RETRY_CURSOR) {
|
|
39621
|
+
try {
|
|
39622
|
+
await deps.client.updateExecutionRun(projectSlug, runId, {
|
|
39623
|
+
status: "pending",
|
|
39624
|
+
leaseId: null,
|
|
39625
|
+
checkpointCursor: EXECUTION_TURN_TIMEOUT_RETRY_CURSOR
|
|
39626
|
+
});
|
|
39627
|
+
await deps.client.appendExecutionArtifact(projectSlug, runId, {
|
|
39628
|
+
id: newArtifactId(),
|
|
39629
|
+
kind: "error",
|
|
39630
|
+
payload: {
|
|
39631
|
+
code: EXECUTION_TURN_TIMEOUT_RETRY_CODE,
|
|
39632
|
+
source: "execution-consumer",
|
|
39633
|
+
attempt: 1,
|
|
39634
|
+
reason,
|
|
39635
|
+
summary: EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY
|
|
39636
|
+
}
|
|
39637
|
+
}).catch(() => {
|
|
39638
|
+
});
|
|
39639
|
+
return;
|
|
39640
|
+
} catch {
|
|
39641
|
+
}
|
|
39642
|
+
}
|
|
39456
39643
|
try {
|
|
39457
39644
|
await deps.client.createExecutionRunHandoff(projectSlug, runId, { reason });
|
|
39458
39645
|
} catch {
|
|
@@ -44092,6 +44279,9 @@ function buildThreadStartParams(msg, config2, desiredModel) {
|
|
|
44092
44279
|
"request behind the task (pure system bookkeeping) \u2014 note that omitting it makes the gate fail-closed.",
|
|
44093
44280
|
"",
|
|
44094
44281
|
"Spec authoring contract \u2014 when creating or updating a `Specs/{id}` wiki page:",
|
|
44282
|
+
"- BEGIN the page with YAML frontmatter naming the gated task: a first line `---`, then",
|
|
44283
|
+
" `task: {taskId}` (the task this spec gates, e.g. `task: GT-1023`), then `---`. Never change it",
|
|
44284
|
+
" to another task id \u2014 it is the machine-readable identity the consensus gate validates against.",
|
|
44095
44285
|
"- In `## Specification`, write each requirement as `### Requirement: <name>` followed by one",
|
|
44096
44286
|
" SHALL sentence stating the externally visible behavior.",
|
|
44097
44287
|
"- Give every requirement at least one `#### Scenario: <name>` with `- WHEN ...` / `- THEN ...`",
|
|
@@ -44136,6 +44326,24 @@ function buildThreadStartParams(msg, config2, desiredModel) {
|
|
|
44136
44326
|
"- Do not create a handoff for transient provider quota/runtime failures, retriable tool",
|
|
44137
44327
|
" errors, or vague uncertainty; retry cheaply or explain the transient issue in chat.",
|
|
44138
44328
|
"",
|
|
44329
|
+
"Deferred-verification contract \u2014 when the RESULT can only be judged later:",
|
|
44330
|
+
"- When a task's implementation is DONE but its outcome cannot be confirmed now because it",
|
|
44331
|
+
" depends on a delay you do not control \u2014 SEO ranking/traffic effects after (re)indexing,",
|
|
44332
|
+
" ad-campaign performance data accumulating, cache/DNS propagation, a scheduled external",
|
|
44333
|
+
" event \u2014 do NOT close the task and do NOT leave it `in_progress`. Move it to",
|
|
44334
|
+
" `awaiting_verification` via `update_task` and set `verificationDueAt` to the ISO-8601",
|
|
44335
|
+
" datetime when the effect should be measurable (e.g. ~2 weeks out for an SEO article).",
|
|
44336
|
+
" Use `followUpAt` instead when it is a softer check-in rather than a hard verification.",
|
|
44337
|
+
"- In the same `update_task`, or in the task notes, record WHAT to measure and HOW so a later",
|
|
44338
|
+
" turn can verify objectively (the metric, the baseline, the success threshold).",
|
|
44339
|
+
"- Do NOT use `awaiting_verification` for results you CAN confirm now \u2014 verify immediately",
|
|
44340
|
+
" (browser/HTTP/tests) and close or advance the task instead. It is only for genuinely",
|
|
44341
|
+
" time-delayed evidence, not for avoiding verification work.",
|
|
44342
|
+
"- When the task is later woken (its `verificationDueAt`/`followUpAt` has passed), gather the",
|
|
44343
|
+
" evidence, then close it \u2014 `closed_done` if the outcome met the bar, or `closed_no_result`",
|
|
44344
|
+
" if it did not (that state REQUIRES a linked Evidence wiki page); or, if the effect still",
|
|
44345
|
+
" is not measurable, re-set `verificationDueAt` to a new date with a reason.",
|
|
44346
|
+
"",
|
|
44139
44347
|
"File artifacts: when you produce a file for the operator (report, HTML, screenshot),",
|
|
44140
44348
|
"upload it with `upload_artifact` (class=temp for one-off downloads, class=durable when a",
|
|
44141
44349
|
"spec/evidence page references it; pass taskId when known) and paste the returned markdown",
|
|
@@ -45016,6 +45224,10 @@ function createBridgeMessageHandler(deps) {
|
|
|
45016
45224
|
const effectiveText = inputText.length > 0 ? inputText : imageDataUri ? DEFAULT_IMAGE_ONLY_PROMPT : "";
|
|
45017
45225
|
if (effectiveText.length > 0) {
|
|
45018
45226
|
const updateOffer = deps.maybeUpdateOffer ? deps.maybeUpdateOffer() : null;
|
|
45227
|
+
const updateOfferProjectSlug = msg.context?.projectSlug?.trim();
|
|
45228
|
+
if (updateOffer !== null && updateOfferProjectSlug && deps.fileUpdateInboxItem) {
|
|
45229
|
+
void deps.fileUpdateInboxItem(updateOfferProjectSlug);
|
|
45230
|
+
}
|
|
45019
45231
|
const composedPrefix = updateOffer ? contextPrefix ? `${updateOffer}
|
|
45020
45232
|
|
|
45021
45233
|
${contextPrefix}` : updateOffer : contextPrefix;
|
|
@@ -45433,7 +45645,32 @@ async function startDirectorBridge() {
|
|
|
45433
45645
|
sessionSinks.unbind(directorSessionId, send);
|
|
45434
45646
|
},
|
|
45435
45647
|
getSessionSink: (directorSessionId) => sessionSinks.get(directorSessionId),
|
|
45436
|
-
maybeUpdateOffer: () => selfUpdateHandle.maybeUpdateOffer()
|
|
45648
|
+
maybeUpdateOffer: () => selfUpdateHandle.maybeUpdateOffer(),
|
|
45649
|
+
fileUpdateInboxItem: async (projectSlug) => {
|
|
45650
|
+
const body = selfUpdateHandle.updateInboxBody();
|
|
45651
|
+
if (!body) return;
|
|
45652
|
+
const id = `II-MCPUPD-${(0, import_node_crypto5.randomUUID)()}`;
|
|
45653
|
+
try {
|
|
45654
|
+
const row = await client.createInboxItem(projectSlug, { id, kind: "question", body });
|
|
45655
|
+
process.stderr.write(
|
|
45656
|
+
`[mrrlin-mcp director-bridge] info bridge.self_update.inbox_filed ${JSON.stringify({
|
|
45657
|
+
projectSlug,
|
|
45658
|
+
itemId: row.id,
|
|
45659
|
+
deduped: row.id !== id
|
|
45660
|
+
})}
|
|
45661
|
+
`
|
|
45662
|
+
);
|
|
45663
|
+
} catch (err) {
|
|
45664
|
+
process.stderr.write(
|
|
45665
|
+
`[mrrlin-mcp director-bridge] warn bridge.self_update.inbox_file_failed ${JSON.stringify({
|
|
45666
|
+
projectSlug,
|
|
45667
|
+
errorClass: "inbox_write_failed",
|
|
45668
|
+
message: String(err?.message ?? err)
|
|
45669
|
+
})}
|
|
45670
|
+
`
|
|
45671
|
+
);
|
|
45672
|
+
}
|
|
45673
|
+
}
|
|
45437
45674
|
};
|
|
45438
45675
|
const bridgeLogger = createBridgeLogger(config2.stateDir, process.env);
|
|
45439
45676
|
let bridgeBinShaShort = "unknown00000";
|
|
@@ -49524,6 +49761,71 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
|
|
|
49524
49761
|
return { outcome: "CONSENSUS_BLOCKED", rounds: input.rounds, finalArtifact: artifact, history };
|
|
49525
49762
|
}
|
|
49526
49763
|
|
|
49764
|
+
// src/consensus/spec-identity.ts
|
|
49765
|
+
function validateSpecIdentity(markdown, taskId) {
|
|
49766
|
+
const target = taskId.toUpperCase();
|
|
49767
|
+
const fieldToken = frontmatterTaskToken(markdown);
|
|
49768
|
+
if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
|
|
49769
|
+
const headingToken = firstHeadingToken(markdown);
|
|
49770
|
+
if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
|
|
49771
|
+
return true;
|
|
49772
|
+
}
|
|
49773
|
+
var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
|
|
49774
|
+
var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
|
|
49775
|
+
var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
|
|
49776
|
+
function frontmatterTaskToken(markdown) {
|
|
49777
|
+
const lines2 = markdown.split("\n");
|
|
49778
|
+
const firstContent = lines2.findIndex((l) => l.trim() !== "");
|
|
49779
|
+
if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
|
|
49780
|
+
for (let i = firstContent + 1; i < lines2.length; i++) {
|
|
49781
|
+
const trimmed = (lines2[i] ?? "").trim();
|
|
49782
|
+
if (trimmed === "---") break;
|
|
49783
|
+
const m = FRONTMATTER_TASK.exec(trimmed);
|
|
49784
|
+
if (m) return firstTaskIdToken(m[1] ?? "");
|
|
49785
|
+
}
|
|
49786
|
+
return null;
|
|
49787
|
+
}
|
|
49788
|
+
function firstHeadingToken(markdown) {
|
|
49789
|
+
const heading = firstHeading(markdown);
|
|
49790
|
+
return heading === null ? null : firstTaskIdToken(heading);
|
|
49791
|
+
}
|
|
49792
|
+
function firstHeading(markdown) {
|
|
49793
|
+
const lines2 = markdown.split("\n");
|
|
49794
|
+
let i = 0;
|
|
49795
|
+
const firstContent = lines2.findIndex((l) => l.trim() !== "");
|
|
49796
|
+
if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
|
|
49797
|
+
i = firstContent + 1;
|
|
49798
|
+
while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
|
|
49799
|
+
i++;
|
|
49800
|
+
}
|
|
49801
|
+
let inFence = false;
|
|
49802
|
+
let inComment = false;
|
|
49803
|
+
for (; i < lines2.length; i++) {
|
|
49804
|
+
const line = lines2[i] ?? "";
|
|
49805
|
+
const trimmed = line.trim();
|
|
49806
|
+
if (inComment) {
|
|
49807
|
+
if (trimmed.includes("-->")) inComment = false;
|
|
49808
|
+
continue;
|
|
49809
|
+
}
|
|
49810
|
+
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
|
49811
|
+
inFence = !inFence;
|
|
49812
|
+
continue;
|
|
49813
|
+
}
|
|
49814
|
+
if (inFence) continue;
|
|
49815
|
+
if (trimmed.startsWith("<!--")) {
|
|
49816
|
+
if (!trimmed.includes("-->")) inComment = true;
|
|
49817
|
+
continue;
|
|
49818
|
+
}
|
|
49819
|
+
const m = ATX_HEADING.exec(line);
|
|
49820
|
+
if (m) return m[1] ?? "";
|
|
49821
|
+
}
|
|
49822
|
+
return null;
|
|
49823
|
+
}
|
|
49824
|
+
function firstTaskIdToken(text) {
|
|
49825
|
+
const m = TASK_ID_TOKEN.exec(text);
|
|
49826
|
+
return m ? m[0] : null;
|
|
49827
|
+
}
|
|
49828
|
+
|
|
49527
49829
|
// src/consensus/spec-gate.ts
|
|
49528
49830
|
var SPEC_HEADS = ["plan-reviewer", "architect", "security-analyst", "scope-analyst"];
|
|
49529
49831
|
async function runSpecGate(_target, deps, opts) {
|
|
@@ -49545,6 +49847,7 @@ async function runSpecGate(_target, deps, opts) {
|
|
|
49545
49847
|
const heads = [...SPEC_HEADS, ...extraHeads.filter((h) => !SPEC_HEADS.includes(h))];
|
|
49546
49848
|
const spec = await deps.readSpec();
|
|
49547
49849
|
let currentHash = spec.contentHash;
|
|
49850
|
+
let currentMarkdown = spec.markdownBody;
|
|
49548
49851
|
let conflicted = false;
|
|
49549
49852
|
const result = await runConsensus({
|
|
49550
49853
|
artifact: spec.markdownBody,
|
|
@@ -49556,12 +49859,20 @@ async function runSpecGate(_target, deps, opts) {
|
|
|
49556
49859
|
revise: async (artifact, accepted) => {
|
|
49557
49860
|
const revised = await deps.runReviser(artifact, accepted);
|
|
49558
49861
|
if (!revised.ok) return artifact;
|
|
49862
|
+
if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
|
|
49863
|
+
process.stderr.write(
|
|
49864
|
+
`[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
|
|
49865
|
+
`
|
|
49866
|
+
);
|
|
49867
|
+
return artifact;
|
|
49868
|
+
}
|
|
49559
49869
|
const cas = await deps.casUpdateSpec(revised.text, currentHash);
|
|
49560
49870
|
if (!cas.ok) {
|
|
49561
49871
|
conflicted = true;
|
|
49562
49872
|
throw new Error("SPEC_CHANGED_DURING_REVIEW");
|
|
49563
49873
|
}
|
|
49564
49874
|
currentHash = cas.contentHash;
|
|
49875
|
+
currentMarkdown = revised.text;
|
|
49565
49876
|
return revised.text;
|
|
49566
49877
|
}
|
|
49567
49878
|
// Only OUR conflict throw is swallowed (→ null). Anything else is a real
|
|
@@ -49571,6 +49882,22 @@ async function runSpecGate(_target, deps, opts) {
|
|
|
49571
49882
|
await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
|
|
49572
49883
|
return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
|
|
49573
49884
|
}
|
|
49885
|
+
if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
|
|
49886
|
+
process.stderr.write(
|
|
49887
|
+
`[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
|
|
49888
|
+
`
|
|
49889
|
+
);
|
|
49890
|
+
return {
|
|
49891
|
+
outcome: "CONSENSUS_BLOCKED",
|
|
49892
|
+
clarifications: [
|
|
49893
|
+
{
|
|
49894
|
+
question: `SPEC_IDENTITY_MISMATCH: this spec page gates ${intent.taskId}, but its heading identifies a different task \u2014 rewrite the spec heading and content for ${intent.taskId}, or re-link the task.`,
|
|
49895
|
+
whyBlocking: "converged spec content belongs to a different task than the one it gates",
|
|
49896
|
+
category: "scope"
|
|
49897
|
+
}
|
|
49898
|
+
]
|
|
49899
|
+
};
|
|
49900
|
+
}
|
|
49574
49901
|
await deps.writeEvidence({
|
|
49575
49902
|
specPageId: spec.pageId,
|
|
49576
49903
|
outcome: result.outcome,
|
|
@@ -49788,6 +50115,21 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
|
|
|
49788
50115
|
var import_node_path13 = __toESM(require("node:path"), 1);
|
|
49789
50116
|
var import_node_url2 = require("node:url");
|
|
49790
50117
|
|
|
50118
|
+
// src/consensus/resolve-gated-task.ts
|
|
50119
|
+
async function resolveGatedTaskId(client, projectSlug, page) {
|
|
50120
|
+
const tasks = await client.listTasks(projectSlug);
|
|
50121
|
+
const linking = tasks.find((t) => t.specWikiPageId === page.id);
|
|
50122
|
+
if (linking) {
|
|
50123
|
+
if (page.taskId !== linking.id) {
|
|
50124
|
+
throw new Error(
|
|
50125
|
+
`spec page ${page.id} is linked by task ${linking.id} (tasks.spec_wiki_page_id) but its wiki_pages.task_id back-pointer is ${page.taskId === null ? "null" : page.taskId} \u2014 refusing to gate with a drifted identity`
|
|
50126
|
+
);
|
|
50127
|
+
}
|
|
50128
|
+
return linking.id;
|
|
50129
|
+
}
|
|
50130
|
+
return page.taskId;
|
|
50131
|
+
}
|
|
50132
|
+
|
|
49791
50133
|
// src/consensus/code-gate-git.ts
|
|
49792
50134
|
var import_node_child_process6 = require("node:child_process");
|
|
49793
50135
|
var DIFF_MAX_BYTES = 2e5;
|
|
@@ -50032,6 +50374,23 @@ function loadPersona(name) {
|
|
|
50032
50374
|
personaCache.set(name, content);
|
|
50033
50375
|
return content;
|
|
50034
50376
|
}
|
|
50377
|
+
function specHeadIntentBlock(taskId, title, seed) {
|
|
50378
|
+
const identity = taskId ? `--- TASK IDENTITY (the spec MUST be about THIS task; if its scope or direction contradicts this identity, raise a \`scope\` issue instead of steering the spec elsewhere) ---
|
|
50379
|
+
TASK ${taskId}: ${title ?? ""}
|
|
50380
|
+
|
|
50381
|
+
` : "";
|
|
50382
|
+
const intent = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
|
|
50383
|
+
${seed}
|
|
50384
|
+
|
|
50385
|
+
` : "";
|
|
50386
|
+
return `${identity}${intent}`;
|
|
50387
|
+
}
|
|
50388
|
+
function reviserIdentityInstructions(taskId, title) {
|
|
50389
|
+
const lock = taskId ? `This spec gates task ${taskId}${title ? ` ("${title}")` : ""}. You MUST NOT change the spec's task identity or heading, retarget it to a different task id, or invert its direction (e.g. live-submission vs preparation-only). Preserve the leading \`task: ${taskId}\` frontmatter EXACTLY; never change it to another task id. Address ONLY the accepted issues.
|
|
50390
|
+
|
|
50391
|
+
` : "";
|
|
50392
|
+
return `${lock}You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.`;
|
|
50393
|
+
}
|
|
50035
50394
|
function buildSpecGateDeps(args) {
|
|
50036
50395
|
const { client, projectSlug, specPageId, cwd, codexExecutable, extraHeadCount = 0 } = args;
|
|
50037
50396
|
const rounds = Number(process.env["MRRLIN_CONSENSUS_ROUNDS"] ?? 3);
|
|
@@ -50042,10 +50401,16 @@ function buildSpecGateDeps(args) {
|
|
|
50042
50401
|
if (!intentPromise) {
|
|
50043
50402
|
intentPromise = (async () => {
|
|
50044
50403
|
const page = await client.getWikiPage(projectSlug, specPageId);
|
|
50045
|
-
|
|
50046
|
-
|
|
50404
|
+
if (!page) {
|
|
50405
|
+
throw new Error(`spec page ${projectSlug}/${specPageId} not found \u2014 cannot gate`);
|
|
50406
|
+
}
|
|
50407
|
+
const taskId = await resolveGatedTaskId(client, projectSlug, { id: page.id, taskId: page.taskId ?? null });
|
|
50408
|
+
if (taskId === null) return { taskId: null, seed: null, title: null };
|
|
50047
50409
|
const task = await client.getTask(projectSlug, taskId);
|
|
50048
|
-
|
|
50410
|
+
if (!task) {
|
|
50411
|
+
throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
|
|
50412
|
+
}
|
|
50413
|
+
return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "" };
|
|
50049
50414
|
})();
|
|
50050
50415
|
}
|
|
50051
50416
|
return intentPromise;
|
|
@@ -50070,11 +50435,8 @@ function buildSpecGateDeps(args) {
|
|
|
50070
50435
|
},
|
|
50071
50436
|
async runHead(personaName, artifact, dismissed) {
|
|
50072
50437
|
const persona = loadPersona(personaName);
|
|
50073
|
-
const { seed } = await loadIntent();
|
|
50074
|
-
const intentBlock =
|
|
50075
|
-
${seed}
|
|
50076
|
-
|
|
50077
|
-
` : "";
|
|
50438
|
+
const { taskId, title, seed } = await loadIntent();
|
|
50439
|
+
const intentBlock = specHeadIntentBlock(taskId, title, seed);
|
|
50078
50440
|
return runCodexExec(
|
|
50079
50441
|
{
|
|
50080
50442
|
prompt: `Review this spec against the operator's intent.
|
|
@@ -50105,6 +50467,7 @@ ${artifact}`,
|
|
|
50105
50467
|
},
|
|
50106
50468
|
async runReviser(artifact, accepted) {
|
|
50107
50469
|
const acceptedList = accepted.map((a) => `- (${a.category}) ${a.text}`).join("\n");
|
|
50470
|
+
const { taskId, title } = await loadIntent();
|
|
50108
50471
|
return runCodexExec(
|
|
50109
50472
|
{
|
|
50110
50473
|
prompt: `Accepted issues:
|
|
@@ -50112,7 +50475,7 @@ ${acceptedList}
|
|
|
50112
50475
|
|
|
50113
50476
|
--- SPEC ---
|
|
50114
50477
|
${artifact}`,
|
|
50115
|
-
developerInstructions:
|
|
50478
|
+
developerInstructions: reviserIdentityInstructions(taskId, title),
|
|
50116
50479
|
cwd,
|
|
50117
50480
|
timeoutMs,
|
|
50118
50481
|
sandbox: "read-only",
|
|
@@ -50263,6 +50626,13 @@ async function cachedResult(client, input) {
|
|
|
50263
50626
|
if (!page) return null;
|
|
50264
50627
|
const review = await client.getSpecConsensusReview(input.projectSlug, input.pageId, page.contentHash);
|
|
50265
50628
|
if (review?.outcome === "CONVERGED") {
|
|
50629
|
+
const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
|
|
50630
|
+
id: page.id,
|
|
50631
|
+
taskId: page.taskId ?? null
|
|
50632
|
+
});
|
|
50633
|
+
if (gatedTaskId !== null && !validateSpecIdentity(page.markdownBody, gatedTaskId)) {
|
|
50634
|
+
return null;
|
|
50635
|
+
}
|
|
50266
50636
|
return { status: "already_converged", payload: { outcome: "CONVERGED", contentHash: page.contentHash } };
|
|
50267
50637
|
}
|
|
50268
50638
|
if (review?.outcome === "CONSENSUS_BLOCKED") {
|
|
@@ -52485,6 +52855,13 @@ Usage:
|
|
|
52485
52855
|
non-loopback URL (see \`mrrlin-mcp serve\` above).
|
|
52486
52856
|
URL and MRRLIN_STAGING are mutually exclusive at
|
|
52487
52857
|
bake time \u2014 URL wins, mirrors serve's resolver.
|
|
52858
|
+
Re-running BARE (no env vars) over a block you
|
|
52859
|
+
already pinned to staging/a remote target is safe:
|
|
52860
|
+
it keeps your env verbatim (reported as
|
|
52861
|
+
'env-preserved') instead of conflicting, so the
|
|
52862
|
+
same setup command doubles as an update command.
|
|
52863
|
+
To CHANGE or REMOVE the baked target, re-run with
|
|
52864
|
+
--force (or the new env vars).
|
|
52488
52865
|
--force replaces an existing conflicting block.
|
|
52489
52866
|
--force-prompts overwrites prompt files that
|
|
52490
52867
|
already exist with different content. WITHOUT it,
|
|
@@ -52531,8 +52908,18 @@ Usage:
|
|
|
52531
52908
|
- MRRLIN_DIRECTOR_BRIDGE_CODEX_IDLE_SECONDS (default 300;
|
|
52532
52909
|
how long the warm codex app-server is kept alive
|
|
52533
52910
|
between turns before it is released)
|
|
52534
|
-
-
|
|
52535
|
-
|
|
52911
|
+
- MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS (default 600;
|
|
52912
|
+
an execution turn is cut only after this long with NO codex
|
|
52913
|
+
signal at all \u2014 long-but-active turns keep running. A stall
|
|
52914
|
+
earns one automatic retry)
|
|
52915
|
+
- MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS (default 3600;
|
|
52916
|
+
overall wall-clock budget for the active-turn portion of one
|
|
52917
|
+
claim attempt, measured from claim. Exceeding it hands off with
|
|
52918
|
+
NO retry. Setup/post-turn hangs are bounded by the stale reaper,
|
|
52919
|
+
not by this budget)
|
|
52920
|
+
- MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (DEPRECATED;
|
|
52921
|
+
unset by default. When set, an extra per-turn hard cap: the
|
|
52922
|
+
effective turn deadline becomes min(this, remaining run budget))
|
|
52536
52923
|
- MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS (default 50;
|
|
52537
52924
|
most-recent artifacts kept in the execution context bundle)
|
|
52538
52925
|
|
|
@@ -52647,6 +53034,10 @@ async function main() {
|
|
|
52647
53034
|
});
|
|
52648
53035
|
process.stderr.write(`[mrrlin-mcp install-codex] ${result.action} ${result.configPath}
|
|
52649
53036
|
`);
|
|
53037
|
+
for (const preserved of result.preservedEnvBlocks) {
|
|
53038
|
+
process.stderr.write(`${renderEnvPreservedNotice(preserved.blockId, preserved.envKeys)}
|
|
53039
|
+
`);
|
|
53040
|
+
}
|
|
52650
53041
|
for (const p of result.prompts) {
|
|
52651
53042
|
process.stderr.write(`[mrrlin-mcp install-codex] prompt ${p.name}: ${p.action} ${p.promptPath}
|
|
52652
53043
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrlin-dev/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mrrlin-mcp": "dist/bin.cjs"
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"@types/ws": "^8.18.1",
|
|
23
23
|
"esbuild": "^0.24.0",
|
|
24
24
|
"tsx": "^4.22.3",
|
|
25
|
+
"@mrrlin/client": "0.0.0",
|
|
25
26
|
"@mrrlin/codex-client": "0.0.0",
|
|
26
27
|
"@mrrlin/director-e2e": "0.0.0",
|
|
27
|
-
"@mrrlin/client": "0.0.0",
|
|
28
|
-
"@mrrlin/schemas": "0.0.0",
|
|
29
28
|
"@mrrlin/tsconfig": "0.0.0",
|
|
29
|
+
"@mrrlin/schemas": "0.0.0",
|
|
30
30
|
"@mrrlin/wiki": "0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|