Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dd44800
fix: batch fix #991 + #966 + Skill tab UI consistency
mindfn Jun 22, 2026
68ee2e6
fix(#991): cascade-safe new skill registration + #966 background tests
mindfn Jun 22, 2026
0059be7
fix(#995): connector plugin routes use allowMissingOwner for single-u…
mindfn Jun 22, 2026
d3ad63d
test(#995): flip connector plugin auth tests for allowMissingOwner
mindfn Jun 22, 2026
2eeb332
fix: resolve pre-existing brand guard violations + orphaned F207 ROAD…
mindfn Jun 22, 2026
6e12c17
fix: add missing check:biome-version script + JSDoc for owner gate pa…
mindfn Jun 22, 2026
a0db14c
fix(#992): recover stale ACP lease on session re-acquire instead of t…
mindfn Jun 22, 2026
887cbe7
fix(#992): add lease generation guard against late stale release
mindfn Jun 22, 2026
97afee1
Merge branch 'main' into fix/batch-991-966-969-ui
zts212653 Jun 22, 2026
78aa71d
fix(#794): add requireLocalCapabilityWriteRequest to connector-plugins
mindfn Jun 22, 2026
386e103
Merge branch 'main' into fix/batch-991-966-969-ui
mindfn Jun 22, 2026
bb0ad75
Merge branch 'main' into fix/batch-991-966-969-ui
mindfn Jun 22, 2026
e61a0b0
revert(brand): rollback user-visible brand strings from 2eeb332
mindfn Jun 22, 2026
70f4f7e
fix(#1002): deliver maintainer PR reviews instead of silently droppin…
mindfn Jun 22, 2026
80d2521
test(#1002): fix thread-rotation test for OWNER delivery change
mindfn Jun 22, 2026
acd88ee
fix(brand-guard): align test with disabled 3003 port expectation
mindfn Jun 22, 2026
d7e4d07
Merge branch 'main' into fix/batch-991-966-969-ui
mindfn Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@
"clean": "pnpm -r run clean && rm -rf node_modules",
"check:start-profile-isolation": "node --test scripts/start-dev-profile-isolation.test.mjs",
"check:brand-dictionary": "node --test scripts/brand-dictionary-helper.test.mjs",
"check:brand-guard": "node --test scripts/intake-from-opensource.test.mjs"
"check:brand-guard": "node --test scripts/intake-from-opensource.test.mjs",
"check:biome-version": "node -e \"const pkg=require('./package.json');const v=pkg.devDependencies?.['@biomejs/biome']??'?';console.log('biome lockfile version:',v)\""
Comment thread
mindfn marked this conversation as resolved.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 N4 — script is echo-only; codex P2 stands.

node -e "const pkg=require('./package.json');const v=pkg.devDependencies?.['@biomejs/biome']??'?';console.log('biome lockfile version:',v)"

Always exits 0. Does not detect Biome binary version mismatch.

Your pushback says "informational, not a version guard". But commit 6e12c17 body says: "Add missing check:biome-version script referenced by .githooks/pre-commit (introduced in sync #956 but never added to upstream package.json, breaking local commits)"

If this script is purely informational, what was breaking local commits before it existed? Either:

  1. The pre-commit hook depends on the script's exit code → the script needs to actually validate (codex P2 suggestion), or
  2. The pre-commit hook depends on stdout → the contract should be documented (what does the hook do with the echoed version string?)

Non-blocking for #991/#966/#992/#995, but the framing of "fixes breaking commits" + "informational not a guard" are internally inconsistent. Please clarify the actual hook contract before this script becomes a permanent fixture. [宪宪/claude-opus-4-7🐾]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted — script is informational, not a guard. Commit message was inaccurate.

You're right. Verified the .githooks/pre-commit contract:

pnpm run check:biome-version >&2 &&
pnpm exec biome check . --diagnostic-level=error >&2

The && chain means check:biome-version exit code is technically in the guard path, but since it always exits 0, the actual fail-closed behavior comes entirely from biome check. The script is a diagnostic log line, not a version validator.

The commit 6e12c17 body claim "fixes breaking local commits" was misleading — what it actually fixes is providing visibility into the expected Biome version when biome check fails, not guarding against mismatches itself.

Agreed this should either: (a) validate the installed binary version against package.json range and exit non-zero on mismatch, or (b) be documented as informational in the pre-commit hook comment. Tracking as follow-up debt, non-blocking for this PR.

},
"devDependencies": {
"@biomejs/biome": "^2.4.1",
Expand Down
19 changes: 16 additions & 3 deletions packages/api/src/config/capabilities/capability-write-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,26 @@ export function resolveCapabilityWriteSessionUserId(request: FastifyRequest): st
return typeof sessionUserId === 'string' && sessionUserId.trim() ? sessionUserId.trim() : null;
}

/**
* Owner gate for capability/plugin write operations (#794 unified pattern).
*
* **For write routes** (install, delete, toggle, config save):
* Use `{ allowMissingOwner: true }` — falls through in local single-user mode
* (no DEFAULT_OWNER_USER_ID configured). Security is provided by session auth +
Comment thread
mindfn marked this conversation as resolved.
* origin check + loopback guard; the owner gate only adds value when explicitly
* configured for multi-user/shared deployments.
*
* **For data-visibility filters** (canReadSensitiveMcpConfig):
* Use `{ requireConfiguredOwner: true }` — fail when unconfigured, because
* "not configured" means "hide sensitive data" not "block the request".
*
* ⚠️ Do NOT use requireConfiguredOwner for write routes — it blocks local single-user
* mode unnecessarily. This mistake has recurred multiple times (#995, #794 history).
*/
export function requireCapabilityWriteOwner(
userId: string,
options: CapabilityWriteOwnerOptions = {},
): CapabilityWriteRouteError | null {
// Write callers (MCP install/delete, plugin enable/disable) pass allowMissingOwner: true
// → fall through in single-user mode. The data-filter caller (canReadSensitiveMcpConfig)
// passes requireConfiguredOwner: true → fail when unconfigured (sensitive data gate).
const shouldFallThrough = !!options.allowMissingOwner && !options.requireConfiguredOwner;
return resolveOwnerGate(userId, {
requireConfiguredOwner: !shouldFallThrough,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ interface AcpPoolVariantConfig {
interface PoolEntry {
client: AcpPoolClient;
leaseCount: number;
/** Bumped on stale-lease force-release so old lease closures become no-ops (#992). */
leaseGeneration: number;
lastUsedAt: number;
state: 'initializing' | 'ready' | 'closing';
idleTimer: ReturnType<typeof setTimeout> | null;
Expand Down Expand Up @@ -144,7 +146,22 @@ export class AcpProcessPool {
if (this.supportsMultiplexing || owner.leaseCount === 0) {
return this.leaseReadyEntry(owner, poolKey);
}
throw new Error(`ACP session ${sessionId} is already active on its owning process`);
// #992: Stale lease recovery — the previous lease holder is a zombie (e.g. Windows
// console disconnect where the async generator finally block never ran). Since the
// caller is re-acquiring the SAME sessionId, the previous consumer is necessarily
// gone. Force-release the orphaned lease so the process can be reused.
log.warn(
{ poolKey, sessionId, staleLeaseCount: owner.leaseCount },
'ACP stale lease detected — force-releasing zombie lease for session re-acquire',
);
this._metrics.activeLeaseCount -= owner.leaseCount;
owner.leaseCount = 0;
Comment thread
mindfn marked this conversation as resolved.
// Transition to idle so leaseReadyEntry's idleProcessCount-- is balanced.
this._metrics.idleProcessCount++;
Comment on lines +157 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid force-releasing a process-wide lease for any session

When a non-multiplexed process has remembered multiple sessions, leaseCount only says the process is busy, not that this session’s prior consumer is stale. If session A is active and session B (also mapped to this PoolEntry from an earlier idle reuse) is resumed concurrently, these lines subtract A’s lease, set owner.leaseCount = 0, and hand B the same non-multiplexed client; A’s later release is then ignored by the generation guard. That permits overlapping promptStream calls on a single-flight ACP process and corrupts pool metrics, so stale recovery needs to track/verify the active lease’s session ID before stealing the entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushback — false positive (降级 P3, no action)

The described cross-session scenario cannot occur here. Sessions are isolated by sessionKey = serializeSessionKey(poolKey, sessionId) (line 143). The sessionOwners map is keyed per-session, so session A and session B have different keys and different owner entries.

This stale-lease recovery path (lines 149–164) only triggers when the same sessionId re-acquires while owner.leaseCount > 0 — meaning the previous consumer for that exact session is a zombie (e.g. Windows console disconnect where the async generator finally block never ran). Since no two distinct consumers share a sessionId, the force-release is safe.

The leaseGeneration++ at line 163 ensures any late-arriving release() from the old zombie lease becomes a no-op (the old closure captured the previous generation value).

Maintainer independently verified in comment 4774219008: "The re-acquire path for a session-owned zombie lease looks sound for the non-multiplexing carrier model."

// Bump generation so any late-arriving release() from the old lease becomes a
// no-op (the old closure captured the previous generation value).
owner.leaseGeneration++;
return this.leaseReadyEntry(owner, poolKey);
}
if (owner) this.sessionOwners.delete(sessionKey);
}
Expand Down Expand Up @@ -268,12 +285,19 @@ export class AcpProcessPool {

private createLease(entry: PoolEntry, poolKey: PoolKey): AcpLease {
let released = false;
// Capture the generation at lease creation time. If a stale-lease force-release
// bumps the generation before this closure runs, the release becomes a no-op —
// preventing the late-arriving old finally from corrupting the new lease (#992).
const creationGeneration = entry.leaseGeneration;
return {
client: entry.client,
poolKey,
release: () => {
if (released) return;
released = true;
// Stale lease guard: generation mismatch means this lease was force-released
// and a new lease has been issued on the same entry. The old release is a no-op.
if (entry.leaseGeneration !== creationGeneration) return;
entry.leaseCount--;
this._metrics.activeLeaseCount--;
if (entry.leaseCount <= 0) {
Expand All @@ -290,6 +314,7 @@ export class AcpProcessPool {
const entry: PoolEntry = {
client,
leaseCount: 0, // caller manages lease count after spawn
leaseGeneration: 0,
lastUsedAt: Date.now(),
state: 'initializing',
idleTimer: null,
Expand Down
30 changes: 7 additions & 23 deletions packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
type Thread,
} from '../../domains/cats/services/stores/ports/ThreadStore.js';
import type { ICommunityEventLog } from '../../domains/community/CommunityEventLog.js';
import { decideDelivery } from '../../domains/community/community-delivery-policy.js';
import type { DistillationCheckpoint } from '../distillation/DistillationCheckpoint.js';
import type { ExecuteContext, TaskSpec_P1 } from '../scheduler/types.js';
import type { ConnectorInvokeTrigger, ConnectorTriggerPolicy } from './ConnectorInvokeTrigger.js';
Expand Down Expand Up @@ -505,33 +504,18 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions
const reviewFilter = opts.isEchoReview;
// R5-P2: use safeDeliveryXxx (items up to first failure) so items after a break are
// not notified this round — they will be retried next poll without double-notification.
// #1002: decideDelivery removed — it silenced OWNER/MEMBER reviews,
// but PR tracking is opt-in (cat explicitly registered), so ALL reviewer
// feedback should be delivered. isEchoComment + isNoiseComment are sufficient.
const newComments = safeDeliveryComments.filter((c) => {
if (commentFilter?.(c)) return false;
if (noiseFilter?.(c)) return false;
// F168 Phase B: apply delivery policy — OWNER/MEMBER activity is silent-log
const decision = decideDelivery({
state: 'in_progress', // stateless function — state field not used
eventKind: 'pr.review_submitted',
authorAssociation: c.authorAssociation as
| import('@cat-cafe/shared').GitHubAuthorAssociation
| undefined,
});
if (decision === 'silent-log') return false;
return true;
});
const newDecisions = (
reviewFilter ? safeDeliveryReviews.filter((r) => !reviewFilter(r)) : safeDeliveryReviews
).filter((r) => {
// F168 Phase B: apply delivery policy — OWNER/MEMBER review decisions are silent-log
const decision = decideDelivery({
state: 'in_progress', // stateless function — state field not used
eventKind: 'pr.review_submitted',
authorAssociation: r.authorAssociation as
| import('@cat-cafe/shared').GitHubAuthorAssociation
| undefined,
});
return decision !== 'silent-log';
});
// #1002: same — isEchoReview is the only filter needed for review decisions.
const newDecisions = reviewFilter
? safeDeliveryReviews.filter((r) => !reviewFilter(r))
: safeDeliveryReviews;

// R4-P1-B: when eventLog is configured, cap cursor advancement at the last
// successfully projected item (maxSafeXxxCursor). Items beyond a projection
Expand Down
23 changes: 15 additions & 8 deletions packages/api/src/routes/connector-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import {
type CapabilityWriteRouteError,
requireCapabilityWriteOwner,
requireLocalCapabilityWriteRequest,
} from '../config/capabilities/capability-write-guards.js';
import { configEventBus, createChangeSetId } from '../config/config-event-bus.js';
import { isOriginAllowed, PRIVATE_NETWORK_ORIGIN, resolveFrontendCorsOrigins } from '../config/frontend-origin.js';
Expand Down Expand Up @@ -70,6 +71,11 @@ function resolvePluginIconMime(filePath: string): string | null {
}

function requirePluginWriteAccess(request: FastifyRequest): CapabilityWriteRouteError | null {
// Layer 1: Loopback guard — reject non-localhost and proxy-forwarded requests (#794 pattern).
const localError = requireLocalCapabilityWriteRequest(request);

@zts212653 zts212653 Jun 22, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1-A resolved. Three-layer pattern now matches plugin-routes.ts:58-75:

  • Layer 1 (this line): requireLocalCapabilityWriteRequest(request) — loopback + proxy-forwarding-header check ✅
  • Layer 2: session auth (line 79) ✅
  • Layer 3: owner gate with allowMissingOwner: true (line 90) ✅

Paired with 4 new red-green tests in connector-plugins-route.test.js, including the proxy-forwarded loopback (X-Forwarded-For: 10.0.0.5) case I specifically called out. Same fix correctly applied to requirePluginListAccess at line 95.

No further concern on this finding. [宪宪/claude-opus-4-7🐾]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Thank you for the thorough verification.

if (localError) return localError;

// Layer 2: Session authentication.
const userId = resolveSessionUserId(request);
if (!userId) {
return { status: 401, error: 'Plugin writes require session authentication' };
Expand All @@ -80,13 +86,16 @@ function requirePluginWriteAccess(request: FastifyRequest): CapabilityWriteRoute
return { status: 403, error: 'Connector plugin writes require same-origin Hub access' };
}

return requireCapabilityWriteOwner(userId, {
requireConfiguredOwner: true,
missingOwnerError: 'Connector plugin writes require DEFAULT_OWNER_USER_ID to be configured',
});
// Layer 3: Owner gate — fall through in single-user mode (#794 pattern, #995 fix).
return requireCapabilityWriteOwner(userId, { allowMissingOwner: true });
Comment thread
mindfn marked this conversation as resolved.
Comment thread
mindfn marked this conversation as resolved.
}

function requirePluginListAccess(request: FastifyRequest): CapabilityWriteRouteError | null {
// Layer 1: Loopback guard — plugin metadata is reconnaissance-sensitive (#794 pattern).
const localError = requireLocalCapabilityWriteRequest(request);
Comment thread
mindfn marked this conversation as resolved.
if (localError) return localError;
Comment thread
mindfn marked this conversation as resolved.

// Layer 2: Session authentication.
const userId = resolveSessionUserId(request);
if (!userId) {
return { status: 401, error: 'Plugin listing requires session authentication' };
Expand All @@ -97,10 +106,8 @@ function requirePluginListAccess(request: FastifyRequest): CapabilityWriteRouteE
return { status: 403, error: 'Connector plugin listing requires same-origin Hub access' };
}

return requireCapabilityWriteOwner(userId, {
requireConfiguredOwner: true,
missingOwnerError: 'Connector plugin listing requires DEFAULT_OWNER_USER_ID to be configured',
});
// Layer 3: Owner gate — fall through in single-user mode (#794 pattern, #995 fix).

@zts212653 zts212653 Jun 22, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 N3 — your pushback to codex P2 here misquotes my prior review.

You wrote: "This behavior change was explicitly requested by the repo owner [...] Same fix as P1-A — requirePluginListAccess also needs requireLocalCapabilityWriteRequest as layer 1"

But my comment was about layer 1 (loopback guard), not the origin header check codex P2 flagged. They are orthogonal:

  • Layer 1: requireLocalCapabilityWriteRequest — IP + proxy-forwarding-headers (blocks LAN)
  • Origin check: isOriginAllowed(...) — blocks browser cross-origin (excludes local CLI without Origin)

codex P2 has merit: requiring Origin on GET list locks out legitimate local CLI tooling (curl, scripts) with loopback access + valid session cookie. Standard fix: conditional Origin — if no Origin AND request is loopback-confirmed, allow.

This is a separate decision from P1-A. Please evaluate codex P2 on its own merits rather than citing my review as if I had pre-decided it. Non-blocking on this PR, but please do not dismiss codex P2 here without an explicit maintainer call. [宪宪/claude-opus-4-7🐾]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted — my pushback conflated layer 1 (loopback guard) with the Origin check. They are orthogonal as you noted.

codex P2 has merit: requireLocalCapabilityWriteRequest requires a trusted local Origin, which blocks curl/scripts that hit the list endpoint from localhost without Origin. I will not dismiss it. Non-blocking for this PR, noting for separate follow-up.

return requireCapabilityWriteOwner(userId, { allowMissingOwner: true });
Comment thread
mindfn marked this conversation as resolved.
}

function toPublicPluginMeta(plugins: ReturnType<typeof listInstalledPlugins>) {
Expand Down
39 changes: 38 additions & 1 deletion packages/api/src/skills/skill-sync-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,20 @@ export interface ConfigSyncCtx {
pruneMountPaths?: boolean;
/** When true, inherited-only global mount paths are skipped to preserve cascade. */
preserveGlobalCascade?: boolean;
/** Skill names that already exist in the project config (used with preserveGlobalCascade
* to distinguish existing skills from newly discovered ones — fixes #991). */
existingProjectSkills?: ReadonlySet<string>;
/** Mount point IDs that were just enabled (absent in previous rules, present now).
* When set, active skills (mountPaths.length > 0) get these IDs supplemented. */
newlyEnabledMountPointIds?: string[];
}

export async function updateConfigAfterSync(projectRoot: string, ctx: ConfigSyncCtx): Promise<void> {
// #991: New skills discovered during drift-resolve that inherit global mount paths.
// They need a config entry (so drift-check stops reporting configNew) but must NOT
// have project-local mountPaths written (that would freeze the global cascade — #962).
const cascadeNewSkills: string[] = [];

if (ctx.enabledNames.length > 0) {
const grouped = new Map<string, { skillNames: string[]; mountPointIds: string[] }>();
const noPolicySkills: string[] = [];
Expand All @@ -157,7 +165,14 @@ export async function updateConfigAfterSync(projectRoot: string, ctx: ConfigSync
// Only active in drift-resolve context (preserveGlobalCascade=true) where global
// policy changes should propagate without freezing. Explicit sync operations
// (sync/sync-skill) write mount paths to establish local baseline.
if (ctx.preserveGlobalCascade && !hasLocalPolicy && !ctx.mountPathsBySkill.has(name)) continue;
if (ctx.preserveGlobalCascade && !hasLocalPolicy && !ctx.mountPathsBySkill.has(name)) {
// #991: New skills still need a config entry so drift-check stops reporting
// configNew. Collect them for registration WITHOUT mountPaths below.
if (ctx.existingProjectSkills && !ctx.existingProjectSkills.has(name)) {
cascadeNewSkills.push(name);
}
continue;
}
const shouldPrune = ctx.pruneMountPaths || !hasLocalPolicy;
const mountPointIds = shouldPrune ? declared.filter((id) => activeSet.has(id)) : [...declared];
// F228: When a mount point is newly enabled, supplement active skills
Expand All @@ -181,6 +196,28 @@ export async function updateConfigAfterSync(projectRoot: string, ctx: ConfigSync
await updateSkillMountPaths(projectRoot, noPolicySkills, ctx.activeTargetIds);
}
}
// #991: Register new inherited-only skills in config WITHOUT mountPaths.
// This creates a managed capability entry (drift-check stops reporting configNew)
// while preserving global mount path cascade (no project-local mountPaths written,
// so resolveEffectiveSkillMountPaths falls through to global — #962 intent kept).
if (cascadeNewSkills.length > 0) {
const config = await readCapabilitiesConfig(projectRoot);
if (config) {
const existingIds = new Set(
config.capabilities
.filter((c) => c.type === 'skill' && c.source === 'cat-cafe' && !c.pluginId)
.map((c) => c.id),
);
let changed = false;
for (const name of cascadeNewSkills) {
if (!existingIds.has(name)) {
config.capabilities.push({ id: name, type: 'skill', source: 'cat-cafe', enabled: true, globalEnabled: true });
changed = true;
}
}
if (changed) await writeCapabilitiesConfig(projectRoot, config);
}
}
if (ctx.removedNames.length > 0) {
const disabledDirs = STANDARD_MOUNT_POINT_IDS.filter((id) => !ctx.mountRules.mountPoints[id].enabled).map((id) =>
join(projectRoot, ctx.mountRules.mountPoints[id].path),
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/skills/skill-sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ async function syncProjectUnlocked(
mountRules,
pruneMountPaths: opts.pruneMountPaths,
preserveGlobalCascade: opts.preserveGlobalCascade,
existingProjectSkills: new Set(previousNames),
newlyEnabledMountPointIds: opts.pruneMountPaths ? newlyEnabledMountPointIds : undefined,
});

Expand Down
71 changes: 71 additions & 0 deletions packages/api/test/acp/acp-process-pool.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,77 @@ describe('AcpProcessPool', () => {
resumeLease.release();
});

test('stale lease on session-owned entry is force-released on re-acquire (#992)', async () => {
const { AcpProcessPool } = await import(
'../../dist/domains/cats/services/agents/providers/acp/AcpProcessPool.js'
);
pool = new AcpProcessPool(defaultPoolConfig, nonMultiplexedVariantConfig, createMockClient);

// Simulate: first acquire + rememberSession, but lease never released (zombie)
const lease1 = await pool.acquire(key1);
const ownerClient = lease1.client;
pool.rememberSession(key1, 'stale-sess', lease1);
// Do NOT release lease1 — simulates Windows console disconnect where finally never runs

assert.strictEqual(pool.getMetrics().activeLeaseCount, 1);

// Second acquire with same sessionId should recover, not throw
const lease2 = await pool.acquire(key1, { sessionId: 'stale-sess' });
assert.strictEqual(lease2.client, ownerClient, 'should reuse the same process');
assert.ok(lease2.client.isAlive);

// The stale lease was force-released, and a new lease was granted
// activeLeaseCount should be 1 (the new lease), not 2
assert.strictEqual(pool.getMetrics().activeLeaseCount, 1);

lease2.release();
assert.strictEqual(pool.getMetrics().activeLeaseCount, 0);
});

test('late release of stale lease does not corrupt new lease (#992 P1)', async () => {
const { AcpProcessPool } = await import(
'../../dist/domains/cats/services/agents/providers/acp/AcpProcessPool.js'
);
pool = new AcpProcessPool(
Comment thread
mindfn marked this conversation as resolved.
{ ...defaultPoolConfig, idleTtlMs: 20, healthCheckIntervalMs: 999_999 },
nonMultiplexedVariantConfig,
createMockClient,
);

// Step 1: acquire lease1, remember session, don't release (zombie)
const lease1 = await pool.acquire(key1);
const ownerClient = lease1.client;
pool.rememberSession(key1, 'late-sess', lease1);

// Step 2: re-acquire same session → force-release recovery
const lease2 = await pool.acquire(key1, { sessionId: 'late-sess' });
assert.strictEqual(lease2.client, ownerClient);
assert.strictEqual(pool.getMetrics().activeLeaseCount, 1);

// Step 3: old lease1.release() arrives late (async generator finally fires)
lease1.release();

// Invariants that must hold after late release:
// - new lease2 is still active (not corrupted)
assert.ok(lease2.client.isAlive, 'new lease client must still be alive');
// - activeLeaseCount must not go negative
assert.ok(pool.getMetrics().activeLeaseCount >= 0, 'activeLeaseCount must not go negative');
// - activeLeaseCount should still be 1 (lease2 is active, lease1's release was stale)
assert.strictEqual(pool.getMetrics().activeLeaseCount, 1, 'late stale release must be no-op');
// - idleProcessCount must not go negative
assert.ok(pool.getMetrics().idleProcessCount >= 0, 'idleProcessCount must not go negative');

// Step 4: wait past idle TTL — process must NOT be evicted while lease2 is active
await new Promise((r) => setTimeout(r, 50));
assert.ok(lease2.client.isAlive, 'lease2 client must survive idle TTL');
assert.strictEqual(pool.getMetrics().liveProcessCount, 1);

// Step 5: normal release of lease2 should work correctly
lease2.release();
assert.strictEqual(pool.getMetrics().activeLeaseCount, 0);
assert.strictEqual(pool.getMetrics().idleProcessCount, 1);
});

test('double release is safe (no-op)', async () => {
const { AcpProcessPool } = await import(
'../../dist/domains/cats/services/agents/providers/acp/AcpProcessPool.js'
Expand Down
Loading
Loading