fix(opencode): preserve packaged runtime env
This commit is contained in:
parent
3bb8e18982
commit
2b50def03e
13 changed files with 782 additions and 46 deletions
166
electron.vite.config.1779130085645.mjs
Normal file
166
electron.vite.config.1779130085645.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// electron.vite.config.ts
|
||||
import { defineConfig } from "electron-vite";
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team";
|
||||
var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8"));
|
||||
var prodDeps = Object.keys(pkg.dependencies || {});
|
||||
var runtimeExternalDeps = /* @__PURE__ */ new Set([
|
||||
"node-pty",
|
||||
"agent-teams-controller",
|
||||
"fastify",
|
||||
"@fastify/cors",
|
||||
"@fastify/static"
|
||||
]);
|
||||
var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d));
|
||||
function nativeModuleStub() {
|
||||
const STUB_ID = "\0native-stub";
|
||||
const NODE_MODULE_RE = /\.node(?:\?.*)?$/;
|
||||
return {
|
||||
name: "native-module-stub",
|
||||
enforce: "pre",
|
||||
resolveId(source) {
|
||||
if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`;
|
||||
return null;
|
||||
},
|
||||
load(id) {
|
||||
if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}";
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
var sentrySourceMapTargets = {
|
||||
main: {
|
||||
assets: ["./dist-electron/main/**/*.{js,cjs,mjs,map}"],
|
||||
filesToDeleteAfterUpload: ["./dist-electron/main/**/*.map"]
|
||||
},
|
||||
renderer: {
|
||||
assets: ["./out/renderer/**/*.{js,cjs,mjs,map}"],
|
||||
filesToDeleteAfterUpload: ["./out/renderer/**/*.map"]
|
||||
}
|
||||
};
|
||||
function createSentryPlugins(target) {
|
||||
if (!process.env.SENTRY_AUTH_TOKEN) return [];
|
||||
return [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? "quant-jump-pro",
|
||||
project: process.env.SENTRY_PROJECT ?? "electron",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false,
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: sentrySourceMapTargets[target]
|
||||
})
|
||||
];
|
||||
}
|
||||
var electron_vite_config_default = defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
nativeModuleStub(),
|
||||
...createSentryPlugins("main")
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Inject DSN at compile time - process.env.SENTRY_DSN is NOT available
|
||||
// at runtime in packaged Electron apps (only during CI build).
|
||||
"process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@preload": resolve(__electron_vite_injected_dirname, "src/preload")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
externalizeDeps: {
|
||||
exclude: bundledDeps
|
||||
},
|
||||
commonjsOptions: {
|
||||
strictRequires: [/node_modules\/.*ssh2\//]
|
||||
},
|
||||
sourcemap: "hidden",
|
||||
outDir: "dist-electron/main",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"),
|
||||
"team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"),
|
||||
"task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"),
|
||||
"team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts")
|
||||
},
|
||||
output: {
|
||||
// CJS format so bundled deps can use __dirname/require.
|
||||
// Use .cjs extension since package.json has "type": "module".
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].cjs",
|
||||
// Set UV_THREADPOOL_SIZE before any module code runs.
|
||||
// Must be in the banner because ESM→CJS hoists imports above top-level code.
|
||||
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
|
||||
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
|
||||
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||
"@preload": resolve(__electron_vite_injected_dirname, "src/preload"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-electron/preload",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts")
|
||||
},
|
||||
output: {
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].js"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
cacheDir: resolve(__electron_vite_injected_dirname, "node_modules/.vite/electron-renderer"),
|
||||
optimizeDeps: {
|
||||
include: ["@codemirror/language-data"],
|
||||
exclude: ["@claude-teams/agent-graph"]
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time)
|
||||
"import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||
"@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
|
||||
"@radix-ui/react-compose-refs": resolve(
|
||||
__electron_vite_injected_dirname,
|
||||
"src/renderer/vendor/radixComposeRefs.ts"
|
||||
),
|
||||
"@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts")
|
||||
}
|
||||
},
|
||||
plugins: [react(), ...createSentryPlugins("renderer")],
|
||||
build: {
|
||||
sourcemap: "hidden",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export {
|
||||
electron_vite_config_default as default
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
|
|
@ -21,6 +23,7 @@ export interface BuildRuntimeBaseEnvOptions {
|
|||
providerBackendId?: string | null;
|
||||
shellEnv?: NodeJS.ProcessEnv | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
mergePathFallbacks?: boolean;
|
||||
}
|
||||
|
||||
function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined {
|
||||
|
|
@ -32,18 +35,43 @@ function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): str
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function mergePathValues(...values: (string | null | undefined)[]): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const value of values) {
|
||||
for (const entry of value?.split(path.delimiter) ?? []) {
|
||||
const normalized = entry.trim();
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
merged.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
|
||||
}
|
||||
|
||||
export function buildRuntimeBaseEnv(options: BuildRuntimeBaseEnvOptions = {}): {
|
||||
env: NodeJS.ProcessEnv;
|
||||
resolvedProviderId: CliProviderId | null;
|
||||
} {
|
||||
const shellEnv = options.shellEnv ?? {};
|
||||
const enrichedEnv = buildEnrichedEnv(options.binaryPath);
|
||||
const mergedPath = options.mergePathFallbacks
|
||||
? mergePathValues(options.env?.PATH, shellEnv.PATH, enrichedEnv.PATH, process.env.PATH)
|
||||
: undefined;
|
||||
const env = {
|
||||
...buildEnrichedEnv(options.binaryPath),
|
||||
...enrichedEnv,
|
||||
...shellEnv,
|
||||
};
|
||||
if (mergedPath) {
|
||||
env.PATH = mergedPath;
|
||||
}
|
||||
|
||||
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||
Object.assign(env, options.env ?? {});
|
||||
if (mergedPath) {
|
||||
env.PATH = mergedPath;
|
||||
}
|
||||
applyAgentTeamsIdentityEnv(env);
|
||||
const policyAppliedEnv = applyOpenCodeAutoUpdatePolicy(env);
|
||||
if (policyAppliedEnv.OPENCODE_DISABLE_AUTOUPDATE === undefined) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export async function buildProviderAwareCliEnv(
|
|||
providerBackendId: options.providerBackendId,
|
||||
shellEnv,
|
||||
env: options.env,
|
||||
mergePathFallbacks: true,
|
||||
});
|
||||
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
|
||||
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import {
|
||||
getClaudeBasePath,
|
||||
getMcpConfigsBasePath,
|
||||
getMcpServerBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -30,6 +32,7 @@ const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
|||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
||||
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||
/**
|
||||
* Stale configs older than this are removed on startup (best-effort).
|
||||
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
|
||||
|
|
@ -175,32 +178,113 @@ function emitProgress(
|
|||
options?.onProgress?.({ phase, message });
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is string {
|
||||
if (!candidate?.trim()) {
|
||||
return false;
|
||||
}
|
||||
return /^node(?:-\d+)?(?:\.exe)?$/i.test(path.basename(candidate.trim()));
|
||||
}
|
||||
|
||||
function getNodeRuntimeCommandCandidates(): string[] {
|
||||
const candidates = [
|
||||
process.env.NODE_BINARY,
|
||||
process.env.npm_node_execpath,
|
||||
'node',
|
||||
looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined,
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return candidates.flatMap((candidate) => {
|
||||
const normalized = candidate?.trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(normalized);
|
||||
return [normalized];
|
||||
});
|
||||
}
|
||||
|
||||
function mergePathValues(...values: (string | undefined)[]): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
if (!segment || seen.has(segment)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(segment);
|
||||
merged.push(segment);
|
||||
}
|
||||
}
|
||||
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
|
||||
}
|
||||
|
||||
function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
};
|
||||
const mergedPath = mergePathValues(shellEnv.PATH, buildMergedCliPath(), process.env.PATH);
|
||||
if (mergedPath) {
|
||||
env.PATH = mergedPath;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||
* Electron binary — NOT node — so we must resolve node separately.
|
||||
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
||||
* Uses the user's shell/enriched PATH so packaged GUI launches do not depend
|
||||
* on the minimal Finder/Dock PATH.
|
||||
*/
|
||||
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
||||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
let shellEnv: NodeJS.ProcessEnv = {};
|
||||
try {
|
||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||
const { stdout } = await execCli('node', ['-e', 'process.stdout.write(process.execPath)'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
shellEnv = await resolveInteractiveShellEnv({
|
||||
onProgress: options?.onProgress
|
||||
? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message)
|
||||
: undefined,
|
||||
});
|
||||
const resolved = stdout.trim();
|
||||
if (resolved) {
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
|
||||
}
|
||||
|
||||
const env = buildNodeResolveEnv(shellEnv);
|
||||
let lastError: unknown = null;
|
||||
for (const command of getNodeRuntimeCommandCandidates()) {
|
||||
try {
|
||||
const { stdout } = await execCli(command, ['-e', 'process.stdout.write(process.execPath)'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
const resolved = stdout.trim();
|
||||
if (!resolved) {
|
||||
throw new Error(`${command} did not report process.execPath`);
|
||||
}
|
||||
_resolvedNodePath = resolved;
|
||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||
return _resolvedNodePath;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
} catch {
|
||||
// node not found or timed out — use bare 'node' and let the OS resolve it
|
||||
}
|
||||
_resolvedNodePath = 'node';
|
||||
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
|
||||
return _resolvedNodePath;
|
||||
|
||||
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
||||
throw new Error(
|
||||
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
||||
lastError ? stringifyError(lastError) : 'no Node.js candidates were available'
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8130,6 +8130,18 @@ export class TeamProvisioningService {
|
|||
return typeof deliveredUserMessageId === 'string' && deliveredUserMessageId.trim().length > 0;
|
||||
}
|
||||
|
||||
private hasOpenCodeAcceptedRuntimePrompt(
|
||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||
): boolean {
|
||||
return Boolean(
|
||||
ledgerRecord?.acceptedAt ||
|
||||
ledgerRecord?.runtimePromptMessageId?.trim() ||
|
||||
ledgerRecord?.lastRuntimePromptMessageId?.trim() ||
|
||||
ledgerRecord?.deliveredUserMessageId?.trim() ||
|
||||
(ledgerRecord?.runtimePromptMessageIds ?? []).some((messageId) => messageId.trim())
|
||||
);
|
||||
}
|
||||
|
||||
private isOpenCodeDeliveryRetryablePendingResponse(input: {
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
|
|
@ -8138,6 +8150,12 @@ export class TeamProvisioningService {
|
|||
if (input.readAllowed) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
input.ledgerRecord.responseState === 'session_stale' &&
|
||||
this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -9405,6 +9423,54 @@ export class TeamProvisioningService {
|
|||
const now = nowIso();
|
||||
const sessionRefreshRetry =
|
||||
input.retry && this.isOpenCodeSessionRefreshRetryRecord(input.ledgerRecord, input.reason);
|
||||
const acceptedPromptSessionStaleObservation =
|
||||
!input.retry &&
|
||||
input.ledgerRecord.responseState === 'session_stale' &&
|
||||
this.hasOpenCodeAcceptedRuntimePrompt(input.ledgerRecord);
|
||||
if (acceptedPromptSessionStaleObservation) {
|
||||
const maxSessionRefreshAttempts =
|
||||
input.ledgerRecord.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
id: input.ledgerRecord.id,
|
||||
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
|
||||
diagnostics: [
|
||||
input.reason,
|
||||
`OpenCode session stayed stale while observing an accepted prompt after ${maxSessionRefreshAttempts} attempt(s).`,
|
||||
],
|
||||
failedAt: now,
|
||||
});
|
||||
}
|
||||
const delayMs = OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS;
|
||||
const nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
|
||||
const ledgerRecord = await input.ledger.markSessionStaleObservationScheduled({
|
||||
id: input.ledgerRecord.id,
|
||||
nextAttemptAt,
|
||||
reason: input.reason,
|
||||
scheduledAt: now,
|
||||
maxSessionRefreshAttempts,
|
||||
diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'],
|
||||
});
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
'opencode_prompt_delivery_response_observed',
|
||||
ledgerRecord,
|
||||
{
|
||||
retry: false,
|
||||
reason: input.reason,
|
||||
observeOnlyAfterAcceptedPrompt: true,
|
||||
sessionRefreshAttempts: ledgerRecord.sessionRefreshAttempts ?? 0,
|
||||
maxSessionRefreshAttempts,
|
||||
}
|
||||
);
|
||||
this.scheduleOpenCodePromptDeliveryWatchdog({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
messageId: input.ledgerRecord.inboxMessageId,
|
||||
delayMs,
|
||||
});
|
||||
return ledgerRecord;
|
||||
}
|
||||
if (sessionRefreshRetry) {
|
||||
const maxSessionRefreshAttempts =
|
||||
input.ledgerRecord.maxSessionRefreshAttempts ??
|
||||
|
|
@ -10659,6 +10725,7 @@ export class TeamProvisioningService {
|
|||
const retryShouldRefreshSessionBeforeObserve =
|
||||
retryDueBeforeObserve &&
|
||||
ledgerRecord.status === 'retry_scheduled' &&
|
||||
!this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) &&
|
||||
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason);
|
||||
if (
|
||||
ledgerRecord.status !== 'pending' &&
|
||||
|
|
@ -10825,6 +10892,23 @@ export class TeamProvisioningService {
|
|||
retry: retryable,
|
||||
reason: pendingReason,
|
||||
});
|
||||
if (ledgerRecord.status === 'failed_terminal') {
|
||||
return {
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
responseState: ledgerRecord.responseState,
|
||||
ledgerStatus: ledgerRecord.status,
|
||||
ledgerRecordId: ledgerRecord.id,
|
||||
laneId: laneIdentity.laneId,
|
||||
visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined,
|
||||
visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined,
|
||||
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
|
||||
diagnostics: ledgerRecord.diagnostics.length
|
||||
? ledgerRecord.diagnostics
|
||||
: [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
|
|
@ -10865,6 +10949,7 @@ export class TeamProvisioningService {
|
|||
if (
|
||||
!forceOpenCodeSessionRefreshReason &&
|
||||
ledgerRecord?.status === 'retry_scheduled' &&
|
||||
!this.hasOpenCodeAcceptedRuntimePrompt(ledgerRecord) &&
|
||||
isOpenCodePromptDeliveryAttemptDue(ledgerRecord) &&
|
||||
this.isOpenCodeSessionRefreshRetryRecord(ledgerRecord, ledgerRecord.lastReason)
|
||||
) {
|
||||
|
|
@ -16505,9 +16590,10 @@ export class TeamProvisioningService {
|
|||
...(input.configuredMember.agentType
|
||||
? ['--agent-type', input.configuredMember.agentType]
|
||||
: []),
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
'--strict-mcp-config',
|
||||
'--disallowedTools',
|
||||
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
|
||||
...(input.run.request.skipPermissions !== false
|
||||
|
|
@ -16686,9 +16772,10 @@ export class TeamProvisioningService {
|
|||
...(input.configuredMember.agentType
|
||||
? ['--agent-type', input.configuredMember.agentType]
|
||||
: []),
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
'--strict-mcp-config',
|
||||
'--disallowedTools',
|
||||
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
|
||||
...(input.run.request.skipPermissions !== false
|
||||
|
|
|
|||
|
|
@ -583,6 +583,38 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
});
|
||||
}
|
||||
|
||||
async markSessionStaleObservationScheduled(input: {
|
||||
id: string;
|
||||
nextAttemptAt: string;
|
||||
reason: string;
|
||||
scheduledAt: string;
|
||||
maxSessionRefreshAttempts?: number;
|
||||
diagnostics?: string[];
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => {
|
||||
const maxSessionRefreshAttempts =
|
||||
record.maxSessionRefreshAttempts ??
|
||||
input.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
const sessionRefreshAttempts = (record.sessionRefreshAttempts ?? 0) + 1;
|
||||
return {
|
||||
...record,
|
||||
status: 'accepted',
|
||||
responseState: 'session_stale',
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
sessionRefreshAttempts,
|
||||
maxSessionRefreshAttempts,
|
||||
lastSessionRefreshReason: input.reason,
|
||||
lastReason: input.reason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, [
|
||||
input.reason,
|
||||
...(input.diagnostics ?? []),
|
||||
]),
|
||||
updatedAt: input.scheduledAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async markRetryAttempted(input: {
|
||||
id: string;
|
||||
attemptedAt: string;
|
||||
|
|
|
|||
|
|
@ -37,11 +37,9 @@ export function buildMergedCliPath(binaryPath?: string | null): string {
|
|||
const cachedEnv = getCachedShellEnv();
|
||||
if (cachedEnv?.PATH) {
|
||||
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
|
||||
extraDirs.push(vendorBinDir);
|
||||
if (process.platform !== 'win32') {
|
||||
extraDirs.push(pathPosix.join(home, '.bun', 'bin'));
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
extraDirs.push(
|
||||
vendorBinDir,
|
||||
pathWin32.join(home, 'AppData', 'Roaming', 'npm'),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
let originalPath: string | undefined;
|
||||
let originalShell: string | undefined;
|
||||
let originalFakeOpenCodeBinDir: string | undefined;
|
||||
let originalFakeNodePath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-prod-preflight-'));
|
||||
|
|
@ -75,6 +76,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
originalPath = process.env.PATH;
|
||||
originalShell = process.env.SHELL;
|
||||
originalFakeOpenCodeBinDir = process.env.FAKE_OPENCODE_BIN_DIR;
|
||||
originalFakeNodePath = process.env.FAKE_NODE_PATH;
|
||||
process.env.PATH = '';
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -99,6 +101,11 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
} else {
|
||||
process.env.FAKE_OPENCODE_BIN_DIR = originalFakeOpenCodeBinDir;
|
||||
}
|
||||
if (originalFakeNodePath === undefined) {
|
||||
delete process.env.FAKE_NODE_PATH;
|
||||
} else {
|
||||
process.env.FAKE_NODE_PATH = originalFakeNodePath;
|
||||
}
|
||||
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
|
|
@ -127,6 +134,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
return { binDir, binaryPath };
|
||||
}
|
||||
|
||||
async function createFakeNodeBinary(binDir: string): Promise<string> {
|
||||
const binaryPath = path.join(binDir, 'node');
|
||||
process.env.FAKE_NODE_PATH = binaryPath;
|
||||
await writeFile(
|
||||
binaryPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-e" ]; then',
|
||||
' printf "%s" "$FAKE_NODE_PATH"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'echo "unexpected node args: $*" >&2',
|
||||
'exit 2',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function createFakeInteractiveShell(binDir: string): Promise<string> {
|
||||
const shellPath = path.join(tempDir!, 'fake-login-shell');
|
||||
process.env.FAKE_OPENCODE_BIN_DIR = binDir;
|
||||
|
|
@ -196,4 +223,26 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
});
|
||||
expect(version.stdout.trim()).toBe('opencode 9.9.9');
|
||||
});
|
||||
|
||||
it('resolves the Agent Teams MCP command to shell Node when GUI PATH is empty', async () => {
|
||||
const { binDir } = await createFakeOpenCodeBinary();
|
||||
const nodePath = await createFakeNodeBinary(binDir);
|
||||
process.env.SHELL = await createFakeInteractiveShell(binDir);
|
||||
|
||||
const providerEnv = await buildProviderAwareCliEnv({
|
||||
providerId: 'opencode',
|
||||
connectionMode: 'augment',
|
||||
shellEnv: {},
|
||||
env: {
|
||||
PATH: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).toBe(nodePath);
|
||||
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND).not.toBe('node');
|
||||
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY).toBeTruthy();
|
||||
expect(providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON).toContain(
|
||||
providerEnv.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY ?? ''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,6 +140,41 @@ describe('buildProviderAwareCliEnv', () => {
|
|||
expect(result.providerArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps enriched PATH entries when a provider shell env has a narrower PATH', async () => {
|
||||
buildEnrichedEnvMock.mockReturnValue({
|
||||
PATH: ['/mock/runtime/bin', '/usr/local/bin', '/usr/bin'].join(path.delimiter),
|
||||
});
|
||||
|
||||
const { buildProviderAwareCliEnv } =
|
||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
binaryPath: '/mock/claude',
|
||||
providerId: 'codex',
|
||||
shellEnv: {
|
||||
PATH: ['/usr/bin', '/bin'].join(path.delimiter),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env.PATH?.split(path.delimiter).slice(0, 4)).toEqual([
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/mock/runtime/bin',
|
||||
'/usr/local/bin',
|
||||
]);
|
||||
const appliedEnv = applyConfiguredConnectionEnvMock.mock.calls[0]?.[0] as NodeJS.ProcessEnv;
|
||||
expect(appliedEnv.PATH?.split(path.delimiter).slice(0, 4)).toEqual([
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/mock/runtime/bin',
|
||||
'/usr/local/bin',
|
||||
]);
|
||||
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'codex',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('passes metadata-only stored API key access through provider env building', async () => {
|
||||
const { buildProviderAwareCliEnv } =
|
||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||
|
|
|
|||
|
|
@ -770,6 +770,57 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks observe-only stale sessions without scheduling another prompt send', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-session-stale-observe-only',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:session-stale-observe-only',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
const accepted = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
sessionId: 'oc-session-1',
|
||||
runtimePromptMessageId: 'msg_prompt_1',
|
||||
deliveryAttemptId: 'attempt-1',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'msg_prompt_1',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
now: '2026-04-25T10:00:01.000Z',
|
||||
});
|
||||
|
||||
const scheduled = await store.markSessionStaleObservationScheduled({
|
||||
id: accepted.id,
|
||||
nextAttemptAt: '2026-04-25T10:00:10.000Z',
|
||||
reason: 'resolved_behavior_changed:old->new',
|
||||
scheduledAt: '2026-04-25T10:00:06.000Z',
|
||||
diagnostics: ['opencode_session_stale_observe_scheduled_after_accepted_prompt'],
|
||||
});
|
||||
|
||||
expect(scheduled.status).toBe('accepted');
|
||||
expect(scheduled.attempts).toBe(1);
|
||||
expect(scheduled.sessionRefreshAttempts).toBe(1);
|
||||
expect(scheduled.runtimePromptMessageIds).toEqual(['msg_prompt_1']);
|
||||
expect(buildOpenCodePromptDeliveryAttemptId(scheduled)).toBe(
|
||||
`${record.id}:2:${record.payloadHash.slice(0, 12)}:refresh1`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat session_stale with action-required diagnostics as a refresh retry', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
|
|
|
|||
|
|
@ -4,12 +4,28 @@ import Module from 'module';
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
type ExecCliMock = (
|
||||
binaryPath: string | null,
|
||||
args: string[],
|
||||
options?: {
|
||||
encoding?: BufferEncoding;
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
}
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
type ResolveInteractiveShellEnvMock = (options?: unknown) => Promise<NodeJS.ProcessEnv>;
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
electronState: {
|
||||
isPackaged: false,
|
||||
version: '9.9.9-test',
|
||||
},
|
||||
execCliMock: vi.fn(async () => ({ stdout: '/mock/node', stderr: '' })),
|
||||
execCliMock: vi.fn<ExecCliMock>(async () => ({ stdout: '/mock/node', stderr: '' })),
|
||||
cachedShellEnv: null as NodeJS.ProcessEnv | null,
|
||||
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
|
||||
async () => ({} as NodeJS.ProcessEnv)
|
||||
),
|
||||
}));
|
||||
|
||||
let mockHomeDir = '';
|
||||
|
|
@ -33,6 +49,15 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
|
||||
return {
|
||||
...actual,
|
||||
getCachedShellEnv: () => hoisted.cachedShellEnv,
|
||||
resolveInteractiveShellEnv: hoisted.resolveInteractiveShellEnvMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
TeamMcpConfigBuilder,
|
||||
|
|
@ -178,6 +203,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
setPackagedMode(false);
|
||||
setResourcesPath(undefined);
|
||||
hoisted.execCliMock.mockClear();
|
||||
hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' });
|
||||
hoisted.cachedShellEnv = null;
|
||||
hoisted.resolveInteractiveShellEnvMock.mockClear();
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -282,7 +311,72 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['-e', 'process.stdout.write(process.execPath)'],
|
||||
expect.objectContaining({ encoding: 'utf-8', timeout: 5000 })
|
||||
expect.objectContaining({
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
env: expect.objectContaining({ PATH: expect.any(String) }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves packaged MCP Node through shell PATH instead of writing a bare node command', async () => {
|
||||
setPackagedMode(true, '2.0.0');
|
||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||
createdDirs.push(resourcesDir);
|
||||
createPackagedServerBundle(resourcesDir, '// packaged server');
|
||||
setResourcesPath(resourcesDir);
|
||||
hoisted.cachedShellEnv = {
|
||||
PATH: ['/mock-shell-node-bin', '/usr/bin'].join(path.delimiter),
|
||||
HOME: '/Users/tester',
|
||||
};
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue(hoisted.cachedShellEnv);
|
||||
hoisted.execCliMock.mockImplementationOnce(async (command, _args, options) => {
|
||||
expect(command).toBe('node');
|
||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin');
|
||||
return { stdout: '/mock-shell-node-bin/node', stderr: '' };
|
||||
});
|
||||
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/mock-shell-node-bin/node');
|
||||
expect(readGeneratedServer(configPath)?.command).not.toBe('node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(expect.any(Object));
|
||||
});
|
||||
|
||||
it('prefers an explicit NODE_BINARY over PATH-based node lookup', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
const previousNodeBinary = process.env.NODE_BINARY;
|
||||
process.env.NODE_BINARY = '/explicit/node';
|
||||
hoisted.execCliMock.mockImplementationOnce(async (command) => {
|
||||
expect(command).toBe('/explicit/node');
|
||||
return { stdout: '/explicit/node', stderr: '' };
|
||||
});
|
||||
|
||||
try {
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/explicit/node');
|
||||
} finally {
|
||||
if (previousNodeBinary === undefined) {
|
||||
delete process.env.NODE_BINARY;
|
||||
} else {
|
||||
process.env.NODE_BINARY = previousNodeBinary;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('fails fast when Node cannot be resolved instead of emitting a broken bare node command', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
hoisted.execCliMock.mockRejectedValue(new Error('spawn node ENOENT'));
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
|
||||
await expect(builder.writeConfigFile()).rejects.toThrow(
|
||||
'Node.js runtime for Agent Teams MCP was not found'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5354,7 +5354,9 @@ describe('TeamProvisioningService', () => {
|
|||
expect(command).toContain("'--agent-id' 'bob@forge-labs-10'");
|
||||
expect(command).toContain("'--team-name' 'forge-labs-10'");
|
||||
expect(command).toContain("'--parent-session-id' 'lead-session-1'");
|
||||
expect(command).toContain("'--setting-sources' 'user,project,local'");
|
||||
expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'");
|
||||
expect(command).not.toContain('--strict-mcp-config');
|
||||
expect(command).toContain("'--model' 'gpt-5.4'");
|
||||
expect(command).toContain("'--effort' 'high'");
|
||||
expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__');
|
||||
|
|
@ -7721,7 +7723,9 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('retries due stale OpenCode sessions instead of observing forever', async () => {
|
||||
it('observes due stale OpenCode sessions without duplicating accepted prompts', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-25T10:00:00.000Z'));
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
|
|
@ -7859,7 +7863,7 @@ describe('TeamProvisioningService', () => {
|
|||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
ledgerStatus: 'accepted',
|
||||
});
|
||||
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -7874,6 +7878,7 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<{
|
||||
status?: string;
|
||||
nextAttemptAt: string | null;
|
||||
diagnostics?: string[];
|
||||
lastReason?: string | null;
|
||||
|
|
@ -7888,6 +7893,7 @@ describe('TeamProvisioningService', () => {
|
|||
maxAttempts: 3,
|
||||
sessionRefreshAttempts: 1,
|
||||
});
|
||||
scheduledEnvelope.data[0].status = 'retry_scheduled';
|
||||
scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z';
|
||||
scheduledEnvelope.data[0].lastReason = 'resolved_behavior_changed:old->new';
|
||||
scheduledEnvelope.data[0].lastSessionRefreshReason = 'resolved_behavior_changed:old->new';
|
||||
|
|
@ -7909,19 +7915,26 @@ describe('TeamProvisioningService', () => {
|
|||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'accepted',
|
||||
});
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({
|
||||
runId: 'opencode-run-bob',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
messageId: 'msg-stale-session',
|
||||
deliveryAttemptId: expect.stringContaining(':refresh1'),
|
||||
forceSessionRefreshReason: 'resolved_behavior_changed:old->new',
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageToMember).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deliveryAttemptId: expect.stringContaining(':refresh'),
|
||||
forceSessionRefreshReason: 'resolved_behavior_changed:old->new',
|
||||
})
|
||||
);
|
||||
const rescheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(rescheduledEnvelope.data[0]).toMatchObject({
|
||||
status: 'accepted',
|
||||
responseState: 'session_stale',
|
||||
sessionRefreshAttempts: 2,
|
||||
nextAttemptAt: '2026-04-25T10:00:15.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -8590,12 +8603,23 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stops repeated OpenCode session refresh loops at the refresh cap', async () => {
|
||||
it.each([
|
||||
{
|
||||
label: 'resolved behavior changes',
|
||||
staleReason: 'resolved_behavior_changed:old->new',
|
||||
staleDiagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
|
||||
},
|
||||
{
|
||||
label: 'action-required reasons',
|
||||
staleReason: 'permission denied',
|
||||
staleDiagnostics: ['permission denied'],
|
||||
},
|
||||
])('bounds accepted OpenCode session-stale observations for $label', async (scenario) => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
const sendMessageToMember = vi.fn(async (deliveryInput: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
memberName: String(deliveryInput.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_refresh_cap',
|
||||
prePromptCursor: 'cursor-refresh-cap',
|
||||
|
|
@ -8612,10 +8636,10 @@ describe('TeamProvisioningService', () => {
|
|||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
const observeMessageDelivery = vi.fn(async (deliveryInput: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
memberName: String(deliveryInput.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
responseObservation: {
|
||||
state: 'session_stale',
|
||||
|
|
@ -8626,9 +8650,9 @@ describe('TeamProvisioningService', () => {
|
|||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'resolved_behavior_changed:old->new',
|
||||
reason: scenario.staleReason,
|
||||
},
|
||||
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
|
||||
diagnostics: scenario.staleDiagnostics,
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({
|
||||
svc,
|
||||
|
|
@ -8663,7 +8687,7 @@ describe('TeamProvisioningService', () => {
|
|||
status: 'accepted',
|
||||
responseState: 'session_stale',
|
||||
nextAttemptAt: '2000-01-01T00:00:00.000Z',
|
||||
lastReason: 'resolved_behavior_changed:old->new',
|
||||
lastReason: scenario.staleReason,
|
||||
sessionRefreshAttempts: 5,
|
||||
maxSessionRefreshAttempts: 5,
|
||||
});
|
||||
|
|
@ -8683,7 +8707,7 @@ describe('TeamProvisioningService', () => {
|
|||
responsePending: false,
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'failed_terminal',
|
||||
reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
|
||||
});
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -8696,7 +8720,7 @@ describe('TeamProvisioningService', () => {
|
|||
attempts: 1,
|
||||
sessionRefreshAttempts: 5,
|
||||
maxSessionRefreshAttempts: 5,
|
||||
lastReason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -14825,6 +14849,92 @@ describe('TeamProvisioningService', () => {
|
|||
expect(run.pendingMemberRestarts.has('forge')).toBe(true);
|
||||
});
|
||||
|
||||
it('launches direct process teammate restarts with normal MCP settings inheritance', async () => {
|
||||
const teamName = 'process-flags-team';
|
||||
const projectPath = path.join(tempProjectsBase, 'process-flags-project');
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4567,
|
||||
stdin: { on: vi.fn(), unref: vi.fn() },
|
||||
stdout: { pipe: vi.fn(), unref: vi.fn() },
|
||||
stderr: { pipe: vi.fn(), unref: vi.fn() },
|
||||
unref: vi.fn(),
|
||||
});
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
|
||||
} as any);
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: ['forge'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.request = { providerId: 'codex', skipPermissions: true };
|
||||
run.detectedSessionId = 'lead-session-1';
|
||||
const configuredMember = {
|
||||
name: 'forge',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
agentType: 'general-purpose',
|
||||
};
|
||||
const config = {
|
||||
name: 'Process Flags Team',
|
||||
projectPath,
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }, configuredMember],
|
||||
};
|
||||
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { CODEX_API_KEY: 'test-openai-key' },
|
||||
authSource: 'openai_api_key',
|
||||
providerArgs: [],
|
||||
}));
|
||||
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
|
||||
fastModeArgs: [],
|
||||
runtimeTurnSettledHookArgs: [],
|
||||
providerArgs: [],
|
||||
settingsArgs: [],
|
||||
extraArgs: [],
|
||||
}));
|
||||
(svc as any).materializeDirectProcessNativeBootstrapContext = vi.fn(async () => ({}));
|
||||
(svc as any).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
|
||||
(svc as any).enqueueDirectRestartPrompt = vi.fn();
|
||||
(svc as any).appendDirectProcessRuntimeEvent = vi.fn(async () => {});
|
||||
|
||||
await (svc as any).launchDirectProcessMemberRestart({
|
||||
run,
|
||||
teamName,
|
||||
displayName: 'Process Flags Team',
|
||||
leadName: 'team-lead',
|
||||
memberName: 'forge',
|
||||
config,
|
||||
configuredMember,
|
||||
persistedRuntimeMembers: [],
|
||||
});
|
||||
|
||||
child.emit('close', 0, null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--teammate-runtime',
|
||||
'headless',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--mcp-config',
|
||||
'/mock/mcp-config.json',
|
||||
])
|
||||
);
|
||||
expect(launchArgs).not.toContain('--strict-mcp-config');
|
||||
});
|
||||
|
||||
it('rejects a second restart request while the first restart is still in flight', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ describe('buildMergedCliPath', () => {
|
|||
expect(parts[parts.length - 1]).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('when shell cache has PATH, uses that instead of static fallback dirs', () => {
|
||||
it('when shell cache has PATH, keeps it first and still adds static fallback dirs', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
||||
mockGetCachedShellEnv.mockReturnValue({ PATH: '/opt/custom/bin:/bin' });
|
||||
const p = buildMergedCliPath(null);
|
||||
|
|
@ -101,8 +101,9 @@ describe('buildMergedCliPath', () => {
|
|||
expect(p).toContain('/bin');
|
||||
expect(p).toContain('/home/testuser/.claude/local/node_modules/.bin');
|
||||
expect(p).toContain('/home/testuser/.bun/bin');
|
||||
expect(p).toContain('/home/testuser/.local/bin');
|
||||
expect(p).toContain('/usr/local/bin');
|
||||
expect(p).toContain('/usr/bin');
|
||||
expect(p).not.toContain('/home/testuser/.local/bin');
|
||||
});
|
||||
|
||||
it('prepends binary directory when binaryPath is set', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue