Concurrency Safety Issue in create_agent_session
Severity: MEDIUM
Tool: create_agent_session
File: actions/setup/js/create_agent_session.cjs
Analysis Date: 2026-08-14
Summary
create_agent_session.cjs keeps its per-run results in a module-level mutable variable (let _allResults = [], line 16) instead of closure-scoped state returned/held by main(). Because Node.js require() caches modules as singletons, this array is shared across every invocation of main() within the same process for the lifetime of the safe-outputs handler process. Every other MCP-server-related handler inspected (add_comment.cjs, push_repo_memory.cjs, mcp_server_core.cjs, safe_outputs_mcp_server.cjs, safe_outputs_append.cjs) uses purely closure-local or parameter-local state, with no comparable module-level mutable array.
Issue Details
Type: Global/Module-Level State
Location: actions/setup/js/create_agent_session.cjs:16 (declaration), :52 (reset in main()), :76,85,129,143 (mutation in handleMessage), :154,163,173-176 (read by exported getters)
Code Pattern:
// Module scope
let _allResults = [];
async function main(config = {}) {
// Reset module-level state for this run
_allResults = [];
...
return async function handleMessage(message) {
...
_allResults.push({ id: taskId, url: taskUrl, success: true });
...
};
}
function getCreateAgentSessionNumber() {
const first = _allResults.find(r => r.success && r.id);
return first ? first.id : "";
}
Race Condition Scenario (currently latent, not yet triggered by production code):
safe_output_handler_manager.cjs currently calls handlerModule.main(config) once per handler type and then processes messages sequentially in a for loop (processMessages, ~line 786), awaiting each messageHandler(...) call before moving to the next message. Under today's sequential-await architecture this specific handler is safe in practice.
- However, the state lives at module scope rather than being returned/closed-over per invocation. If
main() is ever invoked twice in the same process — e.g. a future feature that processes multiple safe-output batches/workflow runs in one long-lived process, parallel/Promise.all-based message dispatch, or unit tests that call main() twice without module cache reset between assertions — the second main() call's _allResults = [] reset will wipe out results still being read by the first run's getCreateAgentSessionNumber() / getCreateAgentSessionUrl() / writeCreateAgentSessionSummary(), or interleaved .push() calls will merge unrelated session results into one array.
- Result:
session_number/session_url outputs and the step summary could reflect the wrong run's agent-session results (data corruption / cross-run contamination), or silently drop results from an earlier concurrent run.
Detailed Analysis
Root Cause
JavaScript module caching means let _allResults = [] is a singleton mutable object for the process lifetime, not per-call state. The code comment at lines 11-13 explicitly justifies this as "module-level variables (rather than closure-only state) allows the handler manager to read final output values after all messages have been processed" — but the same effect can be achieved without global mutable state by having main() return an object exposing handleMessage, getNumber, getUrl, and writeSummary bound to a closure-local array, and having the handler manager call those returned accessors instead of importing module-level functions.
Concurrent Execution Example
// Timeline if main() were ever invoked twice in the same process:
// T=0ms: Run A calls main() -> _allResults = []
// T=1ms: Run A's handleMessage() pushes session A1 -> _allResults = [A1]
// T=2ms: Run B calls main() -> _allResults = [] // Run A's A1 result is WIPED
// T=3ms: Run B's handleMessage() pushes session B1 -> _allResults = [B1]
// T=4ms: Run A calls getCreateAgentSessionNumber() -> returns B1's id, not A1's!
Impact Assessment
- Data Integrity: Wrong
session_number / session_url step outputs could be reported for a given run, and the step summary could mix or drop agent-session results from different runs.
- Reliability: Any future refactor toward parallel handler execution (a natural optimization for I/O-bound safe-output processing) would silently reintroduce this bug without an obvious test failure, since single-threaded sequential tests would still pass.
- Security: No direct security impact identified (no cross-tenant/cross-repo secret exposure), but cross-run data corruption in agent session identifiers could misdirect a
session_number/session_url consumer (e.g., a step that comments a link) to the wrong created task.
Recommended Fix
Approach: State isolation — move _allResults into a per-invocation closure and return the accessor functions from main() instead of exporting module-level getters.
// ✅ SAFE: per-invocation closure state
async function main(config = {}) {
/** `@type` {Array<{id: string, url: string, success: boolean, error?: string}>} */
const allResults = [];
// ... existing setup ...
const handleMessage = async message => {
// ... use allResults.push(...) instead of _allResults.push(...) ...
};
handleMessage.getSessionNumber = () => {
const first = allResults.find(r => r.success && r.id);
return first ? first.id : "";
};
handleMessage.getSessionUrl = () => {
const first = allResults.find(r => r.success && r.url);
return first ? first.url : "";
};
handleMessage.writeSummary = async () => { /* build from allResults */ };
return handleMessage;
}
module.exports = { main };
The handler manager would then call messageHandler.getSessionNumber(), messageHandler.getSessionUrl(), and await messageHandler.writeSummary() on the specific handleMessage instance returned for that run, instead of importing separate module-level functions that read shared global state.
Explanation: Attaching the accessor functions to the returned handleMessage function closes over a const allResults array created fresh per main() call, eliminating the shared mutable module-level variable entirely while preserving the existing "read final output after all messages processed" capability described in the original code comment.
Implementation Steps:
- Convert
let _allResults = [] (module scope) into const allResults = [] declared inside main().
- Replace all
_allResults.* references inside handleMessage with allResults.*.
- Attach
getSessionNumber, getSessionUrl, and writeSummary as properties on the returned handleMessage function (or return { handleMessage, getSessionNumber, getSessionUrl, writeSummary } and update safe_output_handler_manager.cjs's call sites accordingly).
- Update
module.exports and the two import/call sites in safe_output_handler_manager.cjs (where it require("./create_agent_session.cjs") and later invokes getCreateAgentSessionNumber() / getCreateAgentSessionUrl() / writeCreateAgentSessionSummary()).
- Update/extend
create_agent_session.test.cjs to add a concurrency regression test (two independent main() invocations processed "concurrently" must not cross-contaminate results).
Alternative Solutions
Option 1: Keep module-level state but key it by a run ID
- Pros: Smaller code diff.
- Cons: Still shared mutable state; requires plumbing a run ID through every call; more complex than eliminating the shared state.
Option 2: Leave as-is, add explicit non-reentrancy documentation/guard
- Pros: No functional change; add an assertion that
main() is never called twice per process.
- Cons: Doesn't fix the root cause; brittle to future refactors; violates the "safe by construction" goal of the analysis.
Testing Strategy
To verify the fix, add a test where two independent main() invocations are created and one is exercised while checking that the other's accessor state is unaffected (no shared _allResults cross-contamination). Use import() to get fresh handler instances and assert independent result arrays / getter outputs.
References
- JavaScript Concurrency Model: Node.js module caching (
require() singleton semantics) and the single-threaded event loop with interleaved async execution.
- Node.js Best Practices: Prefer closures over module-level mutable state for per-invocation data.
- Related Files Verified Clean:
actions/setup/js/add_comment.cjs, actions/setup/js/push_repo_memory.cjs, actions/setup/js/mcp_server_core.cjs, actions/setup/js/safe_outputs_mcp_server.cjs, actions/setup/js/safe_outputs_append.cjs (no module-level mutable state found in any of these).
Priority: P2-Medium
Effort: Small
Expected Impact: Prevents cross-run data corruption of agent-session results/outputs/summary if handler invocation is ever parallelized or main() is called more than once per process; improves code correctness and testability today.
Generated by 📊 Daily MCP Tool Concurrency Analysis · auto · 71.9 AIC · ⌖ 5.25 AIC · ⊞ 11.4K · ◷
Concurrency Safety Issue in
create_agent_sessionSeverity: MEDIUM
Tool:
create_agent_sessionFile:
actions/setup/js/create_agent_session.cjsAnalysis Date: 2026-08-14
Summary
create_agent_session.cjskeeps its per-run results in a module-level mutable variable (let _allResults = [], line 16) instead of closure-scoped state returned/held bymain(). Because Node.jsrequire()caches modules as singletons, this array is shared across every invocation ofmain()within the same process for the lifetime of the safe-outputs handler process. Every other MCP-server-related handler inspected (add_comment.cjs,push_repo_memory.cjs,mcp_server_core.cjs,safe_outputs_mcp_server.cjs,safe_outputs_append.cjs) uses purely closure-local or parameter-local state, with no comparable module-level mutable array.Issue Details
Type: Global/Module-Level State
Location:
actions/setup/js/create_agent_session.cjs:16(declaration),:52(reset inmain()),:76,85,129,143(mutation inhandleMessage),:154,163,173-176(read by exported getters)Code Pattern:
Race Condition Scenario (currently latent, not yet triggered by production code):
safe_output_handler_manager.cjscurrently callshandlerModule.main(config)once per handler type and then processes messages sequentially in aforloop (processMessages, ~line 786), awaiting eachmessageHandler(...)call before moving to the next message. Under today's sequential-await architecture this specific handler is safe in practice.main()is ever invoked twice in the same process — e.g. a future feature that processes multiple safe-output batches/workflow runs in one long-lived process, parallel/Promise.all-based message dispatch, or unit tests that callmain()twice without module cache reset between assertions — the secondmain()call's_allResults = []reset will wipe out results still being read by the first run'sgetCreateAgentSessionNumber()/getCreateAgentSessionUrl()/writeCreateAgentSessionSummary(), or interleaved.push()calls will merge unrelated session results into one array.session_number/session_urloutputs and the step summary could reflect the wrong run's agent-session results (data corruption / cross-run contamination), or silently drop results from an earlier concurrent run.Detailed Analysis
Root Cause
JavaScript module caching means
let _allResults = []is a singleton mutable object for the process lifetime, not per-call state. The code comment at lines 11-13 explicitly justifies this as "module-level variables (rather than closure-only state) allows the handler manager to read final output values after all messages have been processed" — but the same effect can be achieved without global mutable state by havingmain()return an object exposinghandleMessage,getNumber,getUrl, andwriteSummarybound to a closure-local array, and having the handler manager call those returned accessors instead of importing module-level functions.Concurrent Execution Example
Impact Assessment
session_number/session_urlstep outputs could be reported for a given run, and the step summary could mix or drop agent-session results from different runs.session_number/session_urlconsumer (e.g., a step that comments a link) to the wrong created task.Recommended Fix
Approach: State isolation — move
_allResultsinto a per-invocation closure and return the accessor functions frommain()instead of exporting module-level getters.The handler manager would then call
messageHandler.getSessionNumber(),messageHandler.getSessionUrl(), andawait messageHandler.writeSummary()on the specifichandleMessageinstance returned for that run, instead of importing separate module-level functions that read shared global state.Explanation: Attaching the accessor functions to the returned
handleMessagefunction closes over aconst allResultsarray created fresh permain()call, eliminating the shared mutable module-level variable entirely while preserving the existing "read final output after all messages processed" capability described in the original code comment.Implementation Steps:
let _allResults = [](module scope) intoconst allResults = []declared insidemain()._allResults.*references insidehandleMessagewithallResults.*.getSessionNumber,getSessionUrl, andwriteSummaryas properties on the returnedhandleMessagefunction (or return{ handleMessage, getSessionNumber, getSessionUrl, writeSummary }and updatesafe_output_handler_manager.cjs's call sites accordingly).module.exportsand the two import/call sites insafe_output_handler_manager.cjs(where itrequire("./create_agent_session.cjs")and later invokesgetCreateAgentSessionNumber()/getCreateAgentSessionUrl()/writeCreateAgentSessionSummary()).create_agent_session.test.cjsto add a concurrency regression test (two independentmain()invocations processed "concurrently" must not cross-contaminate results).Alternative Solutions
Option 1: Keep module-level state but key it by a run ID
Option 2: Leave as-is, add explicit non-reentrancy documentation/guard
main()is never called twice per process.Testing Strategy
To verify the fix, add a test where two independent
main()invocations are created and one is exercised while checking that the other's accessor state is unaffected (no shared_allResultscross-contamination). Useimport()to get fresh handler instances and assert independent result arrays / getter outputs.References
require()singleton semantics) and the single-threaded event loop with interleaved async execution.actions/setup/js/add_comment.cjs,actions/setup/js/push_repo_memory.cjs,actions/setup/js/mcp_server_core.cjs,actions/setup/js/safe_outputs_mcp_server.cjs,actions/setup/js/safe_outputs_append.cjs(no module-level mutable state found in any of these).Priority: P2-Medium
Effort: Small
Expected Impact: Prevents cross-run data corruption of agent-session results/outputs/summary if handler invocation is ever parallelized or
main()is called more than once per process; improves code correctness and testability today.