pi-roundtable-mcp 0.7.16 → 0.7.18

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/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.18] - 2026-10-05
9
+
10
+ ### Fixed
11
+
12
+ - Reserve a remote session atomically so simultaneous dispatches cannot overlap.
13
+ - Keep a timed-out session busy until its underlying answer settles, and release it after synchronous or asynchronous answer failures.
14
+
15
+ ### Changed
16
+
17
+ - Track active executions by session instead of scanning historical runs to decide whether a session is busy.
18
+
8
19
  ## [0.7.2] - 2026-10-02
9
20
 
10
21
  The first release from pi-roundtable; 0.7.0 and 0.7.1 were not published for this package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-mcp",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
4
4
  "description": "MCP connectors through ContextForge and a remote MCP endpoint for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "2.5.15",
47
47
  "@types/bun": "1.4.2",
48
- "pi-roundtable": "0.7.16",
48
+ "pi-roundtable": "0.7.18",
49
49
  "typescript": "7.0.2"
50
50
  }
51
51
  }
@@ -50,6 +50,8 @@ export class RemoteAgent {
50
50
  readonly #options: RemoteAgentOptions;
51
51
  readonly #text: Pick<RemoteMcpMessages, "relayNote" | "runFailed">;
52
52
  readonly #runs = new Map<string, Run>();
53
+ /** Actual executions, including calls still settling after a reported timeout. */
54
+ readonly #active = new Map<string, Run>();
53
55
 
54
56
  constructor(options: RemoteAgentOptions) {
55
57
  this.#options = options;
@@ -62,8 +64,16 @@ export class RemoteAgent {
62
64
  ): Promise<{ runId: string; sessionId: string }> {
63
65
  this.#prune();
64
66
  const session = await this.#session(sessionId);
67
+ // Check and reserve without an await between them: concurrent dispatches may
68
+ // both have finished looking up the same idle session.
69
+ if (this.#active.has(session))
70
+ throw new RemoteAgentError("RUN_IN_PROGRESS");
65
71
  const runId = randomUUID();
66
- const run: Run = { sessionId: session, state: { status: "working" } };
72
+ const run: Run = {
73
+ sessionId: session,
74
+ state: { status: "working" },
75
+ };
76
+ this.#active.set(session, run);
67
77
  this.#runs.set(runId, run);
68
78
  void this.#run(run, runId, message);
69
79
  return { runId, sessionId: session };
@@ -75,16 +85,12 @@ export class RemoteAgent {
75
85
  return run.state;
76
86
  }
77
87
 
78
- /** A new session, or the named one when it exists and has no run working. */
88
+ /** A new session, or the named one when it exists. Dispatch reserves it after lookup. */
79
89
  async #session(sessionId: string | undefined): Promise<string> {
80
90
  const { sessions } = this.#options;
81
91
  if (sessionId === undefined) return sessions.create();
82
92
  if (!SESSION_ID.test(sessionId) || !(await sessions.touch(sessionId)))
83
93
  throw new RemoteAgentError("SESSION_NOT_FOUND");
84
- const working = [...this.#runs.values()].some(
85
- (run) => run.sessionId === sessionId && run.state.status === "working",
86
- );
87
- if (working) throw new RemoteAgentError("RUN_IN_PROGRESS");
88
94
  return sessionId;
89
95
  }
90
96
 
@@ -97,30 +103,47 @@ export class RemoteAgent {
97
103
  timeoutMs,
98
104
  );
99
105
  });
100
- const result = await Promise.race([
101
- answer(
102
- remoteChannel(run.sessionId),
103
- `${this.#text.relayNote}\n${message}`,
104
- ),
105
- timedOut,
106
- ]);
107
- clearTimeout(timer);
108
- if (result.ok) {
109
- run.state = { status: "completed", text: result.text };
110
- } else {
106
+ const answerResult = Promise.resolve()
107
+ .then(() =>
108
+ answer(
109
+ remoteChannel(run.sessionId),
110
+ `${this.#text.relayNote}\n${message}`,
111
+ ),
112
+ )
113
+ .catch((error: unknown): TurnResult => {
114
+ return {
115
+ ok: false,
116
+ error: error instanceof Error ? error : new Error(String(error)),
117
+ };
118
+ });
119
+ try {
120
+ const result = await Promise.race([answerResult, timedOut]);
121
+ if (result.ok) {
122
+ run.state = { status: "completed", text: result.text };
123
+ run.finishedAt = Date.now();
124
+ return;
125
+ }
111
126
  logger.error(
112
127
  { runId, sessionId: run.sessionId, err: result.error },
113
128
  "remote run failed",
114
129
  );
115
130
  run.state = { status: "failed", error: this.#text.runFailed };
131
+ run.finishedAt = Date.now();
132
+ await answerResult;
133
+ } finally {
134
+ clearTimeout(timer);
135
+ this.#active.delete(run.sessionId);
116
136
  }
117
- run.finishedAt = Date.now();
118
137
  }
119
138
 
120
139
  #prune(): void {
121
140
  const keepMs = this.#options.keepMs ?? 60 * 60_000;
122
141
  for (const [id, run] of this.#runs) {
123
- if (run.finishedAt !== undefined && Date.now() - run.finishedAt > keepMs)
142
+ if (
143
+ this.#active.get(run.sessionId) !== run &&
144
+ run.finishedAt !== undefined &&
145
+ Date.now() - run.finishedAt > keepMs
146
+ )
124
147
  this.#runs.delete(id);
125
148
  }
126
149
  }