Skip to content

Commit caf0519

Browse files
fix: keep index cache on partial delete; bound unconnected sessions
- moss-vscode: discardedPreviousIndex was set *before* deleteInBatches, so a delete that threw partway still told the caller to drop the persisted cache. Some old documents would survive while the cache — the only record of their ids — was gone, leaving them answering searches for deleted or renamed files with no way for a later rebuild to find them. The flag now flips only after the stale delete has fully succeeded, so a partial delete keeps the cache and the next rebuild retries the cleanup. - server.py: /api/offer accepted an SDP and started the pipeline immediately, with nothing to end a session whose client never completed the WebRTC/RTVI handshake — Whisper, Piper and Ollama stayed loaded until the transport noticed or the process exited. Added a watchdog that shuts the session down if on_client_ready has not fired within SESSION_HANDSHAKE_TIMEOUT_SECS (default 45s, generous against the frontend's own 30s connect timeout so a slow but genuine client is never cut off), cancelled once the session ends. Also bounded concurrency with MAX_ACTIVE_BOTS (default 2), checked before spawning so excess offers get a 503 rather than degrading live sessions. Both documented in the README table and .env.example. Verified: the delete-flag placement across partial-delete, delete-then-throw and success — only flag-after avoids stranding documents; and the watchdog for a client that never connects vs ready at 0.1s and 0.35s, plus the capacity gate at the boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d5eedf commit caf0519

4 files changed

Lines changed: 63 additions & 7 deletions

File tree

apps/moss-interview-coach/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ Open [http://localhost:3000](http://localhost:3000) → pick a track (**System D
8585
| `WHISPER_DEVICE` | no | `auto` |
8686
| `PIPER_VOICE` | no | `en_US-lessac-medium` |
8787
| `GRADE_SUBPROCESS_TIMEOUT_SECS` | no | `60` |
88+
| `SESSION_HANDSHAKE_TIMEOUT_SECS` | no | `45` — ends a session whose client never completes the WebRTC/RTVI handshake |
89+
| `MAX_ACTIVE_BOTS` | no | `2` — further offers get 503 until a slot frees |
8890
| `BACKEND_HOST` | no | `127.0.0.1` |
8991
| `BACKEND_PORT` | no | `8000` |
9092
| `BACKEND_RELOAD` | no | unset — uvicorn autoreload off; set `1` for development only |

apps/moss-interview-coach/backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ PIPER_VOICE=en_US-lessac-medium
2121

2222
# Grader subprocess
2323
GRADE_SUBPROCESS_TIMEOUT_SECS=60
24+
# Ends a session whose client never finishes the WebRTC/RTVI handshake, so a
25+
# dropped offer cannot hold Whisper/Piper/Ollama open.
26+
SESSION_HANDSHAKE_TIMEOUT_SECS=45
27+
# Concurrent interviews. Each loads its own STT/TTS and shares one Ollama.
28+
MAX_ACTIVE_BOTS=2
2429

2530
# Backend
2631
# Loopback by default: /api/offer is unauthenticated, and each call spins up

apps/moss-interview-coach/backend/server.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@
8282
PIPER_VOICE = os.getenv("PIPER_VOICE", "en_US-lessac-medium")
8383
GRADER_WORKER_PATH = Path(__file__).resolve().parent / "grader_worker.py"
8484
GRADE_SUBPROCESS_TIMEOUT_SECS = float(os.getenv("GRADE_SUBPROCESS_TIMEOUT_SECS", "60"))
85+
# Generous relative to the frontend's own 30s connect timeout, so a slow but
86+
# genuine client is never cut off — this only catches offers that go nowhere.
87+
SESSION_HANDSHAKE_TIMEOUT_SECS = float(os.getenv("SESSION_HANDSHAKE_TIMEOUT_SECS", "45"))
88+
# Each session loads Whisper/Piper and drives Ollama, so concurrency is bounded.
89+
MAX_ACTIVE_BOTS = int(os.getenv("MAX_ACTIVE_BOTS", "2"))
8590

8691
COACH_BEHAVIOR = (
8792
"Conduct a live voice interview. Ask probing follow-ups, push for trade-offs, "
@@ -845,9 +850,11 @@ async def on_client_connected(transport: SmallWebRTCTransport, client: Any) -> N
845850
# PipelineWorker enables RTVI by default and add_event_handler appends,
846851
# so this runs alongside pipecat's own set_bot_ready() handler.
847852
greeted = False
853+
client_ready = asyncio.Event()
848854

849855
@worker.rtvi.event_handler("on_client_ready")
850856
async def on_client_ready(rtvi: Any) -> None:
857+
client_ready.set()
851858
# A client that re-sends ready (reconnect) must not replay the
852859
# welcome over an interview already in progress.
853860
nonlocal greeted
@@ -893,8 +900,34 @@ async def on_client_disconnected(transport: SmallWebRTCTransport, client: Any) -
893900
# signal handling; the worker is still torn down from
894901
# on_client_disconnected above.
895902
runner = WorkerRunner(handle_sigint=False, handle_sigterm=False)
896-
await runner.add_workers(worker)
897-
await runner.run()
903+
904+
async def _handshake_watchdog(current: ActiveSession) -> None:
905+
"""Tear the session down if the client never finishes connecting.
906+
907+
/api/offer accepts an SDP and starts the pipeline immediately, so a
908+
caller that never completes the WebRTC/RTVI handshake would leave
909+
Whisper, Piper and Ollama loaded until the transport happened to
910+
notice or the process exited.
911+
"""
912+
try:
913+
await asyncio.wait_for(
914+
client_ready.wait(), timeout=SESSION_HANDSHAKE_TIMEOUT_SECS
915+
)
916+
except asyncio.TimeoutError:
917+
logger.warning(
918+
f"No client handshake within {SESSION_HANDSHAKE_TIMEOUT_SECS:.0f}s "
919+
f"(track={track_id}); ending session."
920+
)
921+
await current.shutdown()
922+
923+
watchdog = asyncio.create_task(_handshake_watchdog(session))
924+
try:
925+
await runner.add_workers(worker)
926+
await runner.run()
927+
finally:
928+
watchdog.cancel()
929+
with suppress(asyncio.CancelledError):
930+
await watchdog
898931
finally:
899932
if session is not None:
900933
active_sessions.discard(session)
@@ -1094,6 +1127,16 @@ async def offer(request: Request) -> dict[str, Any]:
10941127
status_code=503,
10951128
detail=f"Grader worker missing at {GRADER_WORKER_PATH.name}.",
10961129
)
1130+
# Refuse before spawning: every session loads Whisper/Piper and competes for
1131+
# the same local Ollama, so unbounded offers would degrade the live ones.
1132+
if active_bots >= MAX_ACTIVE_BOTS:
1133+
raise HTTPException(
1134+
status_code=503,
1135+
detail=(
1136+
f"At capacity: {active_bots} interview(s) already running "
1137+
f"(MAX_ACTIVE_BOTS={MAX_ACTIVE_BOTS}). Try again shortly."
1138+
),
1139+
)
10971140

10981141
body = await _json_object_body(request)
10991142

apps/moss-vscode/src/indexer/indexer.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,20 @@ export class CodebaseIndexer {
122122
staleIds.push(`${rel}#chunk-${i}`);
123123
}
124124
}
125-
// Past this point the previous index is being destroyed, so any exit that
126-
// is not "ready" leaves the persisted cache describing documents that no
127-
// longer exist. A failure *before* here (scan, session setup) leaves the
128-
// old documents intact, and the cache with them.
129-
this.discardedPreviousIndex = true;
130125
if (staleIds.length) {
131126
await this.deleteInBatches(staleIds);
132127
}
128+
// Only once the stale delete has fully succeeded. If it throws partway,
129+
// some old documents survive — and the persisted cache is the only record
130+
// of their ids, so it must be kept for a later rebuild to retry the
131+
// cleanup. Clearing it there would strand those documents in the index
132+
// permanently, still answering searches for deleted or renamed files.
133+
//
134+
// From here on the previous index really is gone, so any exit that is not
135+
// "ready" must invalidate the cache. A failure before this point (scan,
136+
// session setup, a partial delete) leaves the old documents intact and
137+
// the cache with them.
138+
this.discardedPreviousIndex = true;
133139
this.pathChunkCounts.clear();
134140

135141
let processed = 0;

0 commit comments

Comments
 (0)