authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 19:44:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log6954d1b91c34a437df4f8cfd5141d1a585524424
treeb73bf4929e6c40fb919f174581fddbf1e339fb4c
parent7e46da9f22828aad1707e4a3ecc83c2979d83177

phase 1


11 files changed, 1258 insertions(+), 148 deletions(-)

.plans/16-pr89-review-remediation-phases.md+1
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 - Invalid/false-positive: 3 (excluded)10 - Invalid/false-positive: 3 (excluded)
11 - Duplicate reposts: collapsed11 - Duplicate reposts: collapsed
12 - Unique actionable findings after filtering: 5812 - Unique actionable findings after filtering: 58
13 - Post-rewrite validity audit: 5 additional stale items marked invalid, leaving 53 actionable (`34 valid` + `19 partially-valid`)
1314
14## Phase 0: Canonical Triage Lock15## Phase 0: Canonical Triage Lock
1516
.plans/16a-pr89-phase0-canonical-triage.json+12-107
...@@ -10,11 +10,11 @@...@@ -10,11 +10,11 @@
10 "phase0_generated_at": "2026-02-26T02:49:21.091145Z",10 "phase0_generated_at": "2026-02-26T02:49:21.091145Z",
11 "summary": {11 "summary": {
12 "total_threads": 185,12 "total_threads": 185,
13 "actionable_canonical": 58,13 "actionable_canonical": 53,
14 "actionable_duplicates": 24,14 "actionable_duplicates": 24,
15 "filtered_outdated": 94,15 "filtered_outdated": 94,
16 "filtered_resolved": 6,16 "filtered_resolved": 6,
17 "filtered_invalid": 317 "filtered_invalid": 8
18 },18 },
19 "canonical_items": [19 "canonical_items": [
20 {20 {
...@@ -379,24 +379,6 @@...@@ -379,24 +379,6 @@
379 "macroscopeapp"379 "macroscopeapp"
380 ]380 ]
381 },381 },
382 {
383 "canonical_id": "C021",
384 "area": "Other",
385 "title": "Shared mutable default metadata object causes stale eventId",
386 "severity": "Medium",
387 "path": "apps/server/src/orchestration/decider.ts",
388 "line": 27,
389 "thread_ids": [
390 "PRRT_kwDORLtfbc5wkPaA"
391 ],
392 "thread_indexes": [
393 128
394 ],
395 "duplicate_count": 0,
396 "authors": [
397 "cursor"
398 ]
399 },
400 {382 {
401 "canonical_id": "C022",383 "canonical_id": "C022",
402 "area": "Edge-case parsing/platform behavior",384 "area": "Edge-case parsing/platform behavior",
...@@ -451,24 +433,6 @@...@@ -451,24 +433,6 @@
451 "macroscopeapp"433 "macroscopeapp"
452 ]434 ]
453 },435 },
454 {
455 "canonical_id": "C025",
456 "area": "Checkpointing correctness",
457 "title": "Duplicated checkpoint ref computation across two files",
458 "severity": "Medium",
459 "path": "apps/server/src/wsServer.ts",
460 "line": 128,
461 "thread_ids": [
462 "PRRT_kwDORLtfbc5wvwag"
463 ],
464 "thread_indexes": [
465 148
466 ],
467 "duplicate_count": 0,
468 "authors": [
469 "cursor"
470 ]
471 },
472 {436 {
473 "canonical_id": "C026",437 "canonical_id": "C026",
474 "area": "WebSocket robustness",438 "area": "WebSocket robustness",
...@@ -561,24 +525,6 @@...@@ -561,24 +525,6 @@
561 "macroscopeapp"525 "macroscopeapp"
562 ]526 ]
563 },527 },
564 {
565 "canonical_id": "C031",
566 "area": "Event ordering and state consistency",
567 "title": "Revert uses wrong turn count from positional inference",
568 "severity": "Medium",
569 "path": "apps/web/src/session-logic.ts",
570 "line": 127,
571 "thread_ids": [
572 "PRRT_kwDORLtfbc5v9SCp"
573 ],
574 "thread_indexes": [
575 41
576 ],
577 "duplicate_count": 0,
578 "authors": [
579 "cursor"
580 ]
581 },
582 {528 {
583 "canonical_id": "C032",529 "canonical_id": "C032",
584 "area": "Other",530 "area": "Other",
...@@ -657,24 +603,6 @@...@@ -657,24 +603,6 @@
657 "cursor"603 "cursor"
658 ]604 ]
659 },605 },
660 {
661 "canonical_id": "C036",
662 "area": "Checkpointing correctness",
663 "title": "Duplicate `checkpointRefForThreadTurn` function in two production files",
664 "severity": "Low",
665 "path": "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
666 "line": 284,
667 "thread_ids": [
668 "PRRT_kwDORLtfbc5wiqFX"
669 ],
670 "thread_indexes": [
671 115
672 ],
673 "duplicate_count": 0,
674 "authors": [
675 "cursor"
676 ]
677 },
678 {606 {
679 "canonical_id": "C037",607 "canonical_id": "C037",
680 "area": "Other",608 "area": "Other",
...@@ -1003,24 +931,6 @@...@@ -1003,24 +931,6 @@
1003 "macroscopeapp"931 "macroscopeapp"
1004 ]932 ]
1005 },933 },
1006 {
1007 "canonical_id": "C055",
1008 "area": "Checkpointing correctness",
1009 "title": "Duplicate `checkpointRefForThreadTurn` function across files",
1010 "severity": "Low",
1011 "path": "apps/server/src/wsServer.ts",
1012 "line": 128,
1013 "thread_ids": [
1014 "PRRT_kwDORLtfbc5wkPaG"
1015 ],
1016 "thread_indexes": [
1017 129
1018 ],
1019 "duplicate_count": 0,
1020 "authors": [
1021 "cursor"
1022 ]
1023 },
1024 {934 {
1025 "canonical_id": "C056",935 "canonical_id": "C056",
1026 "area": "Other",936 "area": "Other",
...@@ -1684,9 +1594,8 @@...@@ -1684,9 +1594,8 @@
1684 "title": "Revert uses wrong turn count from positional inference",1594 "title": "Revert uses wrong turn count from positional inference",
1685 "severity": "Medium",1595 "severity": "Medium",
1686 "has_false_positive_signal": false,1596 "has_false_positive_signal": false,
1687 "canonical_id": "C031",1597 "phase0_status": "filtered_invalid",
1688 "phase0_status": "actionable_canonical",1598 "phase0_reason": "Invalid after post-rewrite audit: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary."
1689 "phase0_reason": "Canonical actionable item."
1690 },1599 },
1691 {1600 {
1692 "index": 42,1601 "index": 42,
...@@ -2740,9 +2649,8 @@...@@ -2740,9 +2649,8 @@
2740 "title": "Duplicate `checkpointRefForThreadTurn` function in two production files",2649 "title": "Duplicate `checkpointRefForThreadTurn` function in two production files",
2741 "severity": "Low",2650 "severity": "Low",
2742 "has_false_positive_signal": false,2651 "has_false_positive_signal": false,
2743 "canonical_id": "C036",2652 "phase0_status": "filtered_invalid",
2744 "phase0_status": "actionable_canonical",2653 "phase0_reason": "Invalid after post-rewrite audit: No longer duplicated; single production source via Refs.ts."
2745 "phase0_reason": "Canonical actionable item."
2746 },2654 },
2747 {2655 {
2748 "index": 116,2656 "index": 116,
...@@ -2929,9 +2837,8 @@...@@ -2929,9 +2837,8 @@
2929 "title": "Shared mutable default metadata object causes stale eventId",2837 "title": "Shared mutable default metadata object causes stale eventId",
2930 "severity": "Medium",2838 "severity": "Medium",
2931 "has_false_positive_signal": false,2839 "has_false_positive_signal": false,
2932 "canonical_id": "C021",2840 "phase0_status": "filtered_invalid",
2933 "phase0_status": "actionable_canonical",2841 "phase0_reason": "Invalid after post-rewrite audit: Stale-eventId claim no longer applies; eventId is regenerated per event."
2934 "phase0_reason": "Canonical actionable item."
2935 },2842 },
2936 {2843 {
2937 "index": 129,2844 "index": 129,
...@@ -2944,9 +2851,8 @@...@@ -2944,9 +2851,8 @@
2944 "title": "Duplicate `checkpointRefForThreadTurn` function across files",2851 "title": "Duplicate `checkpointRefForThreadTurn` function across files",
2945 "severity": "Low",2852 "severity": "Low",
2946 "has_false_positive_signal": false,2853 "has_false_positive_signal": false,
2947 "canonical_id": "C055",2854 "phase0_status": "filtered_invalid",
2948 "phase0_status": "actionable_canonical",2855 "phase0_reason": "Invalid after post-rewrite audit: No longer duplicated; helper is centralized."
2949 "phase0_reason": "Canonical actionable item."
2950 },2856 },
2951 {2857 {
2952 "index": 130,2858 "index": 130,
...@@ -3226,9 +3132,8 @@...@@ -3226,9 +3132,8 @@
3226 "title": "Duplicated checkpoint ref computation across two files",3132 "title": "Duplicated checkpoint ref computation across two files",
3227 "severity": "Medium",3133 "severity": "Medium",
3228 "has_false_positive_signal": false,3134 "has_false_positive_signal": false,
3229 "canonical_id": "C025",3135 "phase0_status": "filtered_invalid",
3230 "phase0_status": "actionable_canonical",3136 "phase0_reason": "Invalid after post-rewrite audit: No longer duplicated; checkpoint ref helper now centralized."
3231 "phase0_reason": "Canonical actionable item."
3232 },3137 },
3233 {3138 {
3234 "index": 149,3139 "index": 149,
.plans/16a-pr89-phase0-canonical-triage.md+31-31
...@@ -7,20 +7,18 @@...@@ -7,20 +7,18 @@
7## Summary7## Summary
88
9- Total threads: 1859- Total threads: 185
10- Actionable canonical: 5810- Actionable canonical: 53
11- Actionable duplicates: 2411- Actionable duplicates: 24
12- Filtered outdated: 9412- Filtered outdated: 94
13- Filtered resolved: 613- Filtered resolved: 6
14- Filtered invalid: 314- Filtered invalid: 8
1515
16## Canonical Actionable Checklist16## Canonical Actionable Checklist
1717
18### Other (17)18### Other (16)
1919
20- [ ] C011 [Medium] Template literal `${cause}` throws if `cause` is a `Symbol`. Consider using `String(cause)` instead. (apps/server/src/orchestration/Errors.ts:117)20- [ ] C011 [Medium] Template literal `${cause}` throws if `cause` is a `Symbol`. Consider using `String(cause)` instead. (apps/server/src/orchestration/Errors.ts:117)
21 - Threads: PRRT_kwDORLtfbc5wBFv421 - Threads: PRRT_kwDORLtfbc5wBFv4
22- [ ] C021 [Medium] Shared mutable default metadata object causes stale eventId (apps/server/src/orchestration/decider.ts:27)
23 - Threads: PRRT_kwDORLtfbc5wkPaA
24- [ ] C024 [Medium] In `executeUnprepared`, preparing outside an effect and not closing the statement can cause uncaught errors and leaks. Suggest wrapping with `Effect.acquireUseRelease`: prepare in acquire (error-safe) and `finalize` in release. (apps/server/src/persistence/NodeSqliteClient.ts:145)22- [ ] C024 [Medium] In `executeUnprepared`, preparing outside an effect and not closing the statement can cause uncaught errors and leaks. Suggest wrapping with `Effect.acquireUseRelease`: prepare in acquire (error-safe) and `finalize` in release. (apps/server/src/persistence/NodeSqliteClient.ts:145)
25 - Threads: PRRT_kwDORLtfbc5whxX923 - Threads: PRRT_kwDORLtfbc5whxX9
26- [ ] C028 [Medium] Branch sync dispatches both server and stale local update (apps/web/src/components/BranchToolbar.tsx:102)24- [ ] C028 [Medium] Branch sync dispatches both server and stale local update (apps/web/src/components/BranchToolbar.tsx:102)
...@@ -54,15 +52,15 @@...@@ -54,15 +52,15 @@
5452
55### Runtime resilience and failure handling (10)53### Runtime resilience and failure handling (10)
5654
57- [ ] C002 [High] A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333) (+5 dup)55- [x] C002 [High] A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333) (+5 dup)
58 - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT56 - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT
59- [ ] C012 [Medium] Forked revert dispatch risks read model inconsistency (apps/server/src/orchestration/Layers/CheckpointReactor.ts:542) (+4 dup)57- [x] C012 [Medium] Forked revert dispatch risks read model inconsistency (apps/server/src/orchestration/Layers/CheckpointReactor.ts:542) (+4 dup)
60 - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX58 - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX
61- [ ] C019 [Medium] ProviderRuntimeIngestion processes events for wrong thread on race (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178)59- [ ] C019 [Medium] ProviderRuntimeIngestion processes events for wrong thread on race (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178)
62 - Threads: PRRT_kwDORLtfbc5wkPaL60 - Threads: PRRT_kwDORLtfbc5wkPaL
63- [ ] C020 [Medium] On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266)61- [ ] C020 [Medium] On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266)
64 - Threads: PRRT_kwDORLtfbc5w1GPr62 - Threads: PRRT_kwDORLtfbc5w1GPr
65- [ ] C027 [Medium] WS message handler can cause unhandled promise rejection (apps/server/src/wsServer.ts:545) (+1 dup)63- [x] C027 [Medium] WS message handler can cause unhandled promise rejection (apps/server/src/wsServer.ts:545) (+1 dup)
66 - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli364 - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli3
67- [ ] C042 [Low] Duplicated `resolveThreadWorkspaceCwd` across three files (apps/server/src/orchestration/Layers/CheckpointReactor.ts:62)65- [ ] C042 [Low] Duplicated `resolveThreadWorkspaceCwd` across three files (apps/server/src/orchestration/Layers/CheckpointReactor.ts:62)
68 - Threads: PRRT_kwDORLtfbc5wzli266 - Threads: PRRT_kwDORLtfbc5wzli2
...@@ -96,26 +94,7 @@...@@ -96,26 +94,7 @@
96- [ ] C040 [Low] `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/keybindings.ts:488)94- [ ] C040 [Low] `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/keybindings.ts:488)
97 - Threads: PRRT_kwDORLtfbc5wxvIA95 - Threads: PRRT_kwDORLtfbc5wxvIA
9896
99### Checkpointing correctness (8)97### Event ordering and state consistency (5)
100
101- [ ] C008 [Medium] Inconsistent input normalization across CheckpointStore methods (apps/server/src/checkpointing/Layers/CheckpointStore.ts:94) (+3 dup)
102 - Threads: PRRT_kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv_, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36
103- [ ] C017 [Medium] `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71)
104 - Threads: PRRT_kwDORLtfbc5wiLhQ
105- [ ] C025 [Medium] Duplicated checkpoint ref computation across two files (apps/server/src/wsServer.ts:128)
106 - Threads: PRRT_kwDORLtfbc5wvwag
107- [ ] C033 [Low] Three error classes defined but never instantiated anywhere (apps/server/src/checkpointing/Errors.ts:51)
108 - Threads: PRRT_kwDORLtfbc5wlYgo
109- [ ] C034 [Low] Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type (apps/server/src/checkpointing/Errors.ts:79)
110 - Threads: PRRT_kwDORLtfbc5wj5fn
111- [ ] C035 [Low] Redundant error type in CheckpointServiceError union definition (apps/server/src/checkpointing/Errors.ts:79) (+2 dup)
112 - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B
113- [ ] C036 [Low] Duplicate `checkpointRefForThreadTurn` function in two production files (apps/server/src/checkpointing/Layers/CheckpointStore.ts:284)
114 - Threads: PRRT_kwDORLtfbc5wiqFX
115- [ ] C055 [Low] Duplicate `checkpointRefForThreadTurn` function across files (apps/server/src/wsServer.ts:128)
116 - Threads: PRRT_kwDORLtfbc5wkPaG
117
118### Event ordering and state consistency (6)
11998
120- [ ] C001 [High] Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96)99- [ ] C001 [High] Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96)
121 - Threads: PRRT_kwDORLtfbc5wzp4T100 - Threads: PRRT_kwDORLtfbc5wzp4T
...@@ -127,14 +106,12 @@...@@ -127,14 +106,12 @@
127 - Threads: PRRT_kwDORLtfbc5whxJO106 - Threads: PRRT_kwDORLtfbc5whxJO
128- [ ] C016 [Medium] The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490)107- [ ] C016 [Medium] The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490)
129 - Threads: PRRT_kwDORLtfbc5wxvH8108 - Threads: PRRT_kwDORLtfbc5wxvH8
130- [ ] C031 [Medium] Revert uses wrong turn count from positional inference (apps/web/src/session-logic.ts:127)
131 - Threads: PRRT_kwDORLtfbc5v9SCp
132109
133### WebSocket robustness (5)110### WebSocket robustness (5)
134111
135- [ ] C003 [High] Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. (apps/server/src/wsServer.ts:75)112- [ ] C003 [High] Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. (apps/server/src/wsServer.ts:75)
136 - Threads: PRRT_kwDORLtfbc5v-cf4113 - Threads: PRRT_kwDORLtfbc5v-cf4
137- [ ] C026 [Medium] Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. (apps/server/src/wsServer.ts:545)114- [x] C026 [Medium] Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. (apps/server/src/wsServer.ts:545)
138 - Threads: PRRT_kwDORLtfbc5wj4cE115 - Threads: PRRT_kwDORLtfbc5wj4cE
139- [ ] C053 [Low] Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. (apps/server/src/wsServer.ts:83)116- [ ] C053 [Low] Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. (apps/server/src/wsServer.ts:83)
140 - Threads: PRRT_kwDORLtfbc5v-WPD117 - Threads: PRRT_kwDORLtfbc5v-WPD
...@@ -143,6 +120,19 @@...@@ -143,6 +120,19 @@
143- [ ] C059 [Low] Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. (apps/web/src/wsTransport.ts:59)120- [ ] C059 [Low] Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. (apps/web/src/wsTransport.ts:59)
144 - Threads: PRRT_kwDORLtfbc5whtrN121 - Threads: PRRT_kwDORLtfbc5whtrN
145122
123### Checkpointing correctness (5)
124
125- [ ] C008 [Medium] Inconsistent input normalization across CheckpointStore methods (apps/server/src/checkpointing/Layers/CheckpointStore.ts:94) (+3 dup)
126 - Threads: PRRT_kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv_, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36
127- [ ] C017 [Medium] `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71)
128 - Threads: PRRT_kwDORLtfbc5wiLhQ
129- [ ] C033 [Low] Three error classes defined but never instantiated anywhere (apps/server/src/checkpointing/Errors.ts:51)
130 - Threads: PRRT_kwDORLtfbc5wlYgo
131- [ ] C034 [Low] Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type (apps/server/src/checkpointing/Errors.ts:79)
132 - Threads: PRRT_kwDORLtfbc5wj5fn
133- [ ] C035 [Low] Redundant error type in CheckpointServiceError union definition (apps/server/src/checkpointing/Errors.ts:79) (+2 dup)
134 - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B
135
146### Build/runtime portability (2)136### Build/runtime portability (2)
147137
148- [ ] C006 [Medium] Build script runs TypeScript file with bare node (apps/server/package.json:14)138- [ ] C006 [Medium] Build script runs TypeScript file with bare node (apps/server/package.json:14)
...@@ -161,8 +151,18 @@...@@ -161,8 +151,18 @@
161 - Rationale: Invalid by design: provider runtime events are intentionally surfaced through orchestration domain events (`orchestration.domainEvent`), not a separate `providers.event` WebSocket channel in the current architecture.151 - Rationale: Invalid by design: provider runtime events are intentionally surfaced through orchestration domain events (`orchestration.domainEvent`), not a separate `providers.event` WebSocket channel in the current architecture.
162- PRRT_kwDORLtfbc5v9An- | apps/web/src/wsNativeApi.ts:165 | push stream, and the callbacks rely on unchecked casts. Please reintroduce a `providers.onEvent` (or equivalent) that subscribes to the `providers.event` channel and decode read-model/domain-event payloads using the shared contracts schemas before invoking callbacks. As per coding guidelines: "Web app must consume provider event streams via WebSocket push on channel `providers.event`" and "Use Zod schemas from `packages/contracts` for shared type contracts covering provider events, WebSocket protocol, and model/session types". <details> <summary>🤖 Prompt for AI Agents</summary>152- PRRT_kwDORLtfbc5v9An- | apps/web/src/wsNativeApi.ts:165 | push stream, and the callbacks rely on unchecked casts. Please reintroduce a `providers.onEvent` (or equivalent) that subscribes to the `providers.event` channel and decode read-model/domain-event payloads using the shared contracts schemas before invoking callbacks. As per coding guidelines: "Web app must consume provider event streams via WebSocket push on channel `providers.event`" and "Use Zod schemas from `packages/contracts` for shared type contracts covering provider events, WebSocket protocol, and model/session types". <details> <summary>🤖 Prompt for AI Agents</summary>
163 - Rationale: Invalid by design: provider runtime events are intentionally surfaced through orchestration domain events (`orchestration.domainEvent`), not a separate `providers.event` WebSocket channel in the current architecture.153 - Rationale: Invalid by design: provider runtime events are intentionally surfaced through orchestration domain events (`orchestration.domainEvent`), not a separate `providers.event` WebSocket channel in the current architecture.
154- PRRT_kwDORLtfbc5v9SCp | apps/web/src/session-logic.ts:127 | Revert uses wrong turn count from positional inference
155 - Rationale: Invalid after post-rewrite audit: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary.
164- PRRT_kwDORLtfbc5widJx | apps/server/package.json:14 | Build script requires implicit Node.js TypeScript support156- PRRT_kwDORLtfbc5widJx | apps/server/package.json:14 | Build script requires implicit Node.js TypeScript support
165 - Rationale: Thread contains explicit false-positive determination in replies.157 - Rationale: Thread contains explicit false-positive determination in replies.
158- PRRT_kwDORLtfbc5wiqFX | apps/server/src/checkpointing/Layers/CheckpointStore.ts:284 | Duplicate `checkpointRefForThreadTurn` function in two production files
159 - Rationale: Invalid after post-rewrite audit: No longer duplicated; single production source via Refs.ts.
160- PRRT_kwDORLtfbc5wkPaA | apps/server/src/orchestration/decider.ts:27 | Shared mutable default metadata object causes stale eventId
161 - Rationale: Invalid after post-rewrite audit: Stale-eventId claim no longer applies; eventId is regenerated per event.
162- PRRT_kwDORLtfbc5wkPaG | apps/server/src/wsServer.ts:128 | Duplicate `checkpointRefForThreadTurn` function across files
163 - Rationale: Invalid after post-rewrite audit: No longer duplicated; helper is centralized.
164- PRRT_kwDORLtfbc5wvwag | apps/server/src/wsServer.ts:128 | Duplicated checkpoint ref computation across two files
165 - Rationale: Invalid after post-rewrite audit: No longer duplicated; checkpoint ref helper now centralized.
166166
167## Duplicate Mapping167## Duplicate Mapping
168168
.plans/16b-pr89-architecture-validity-audit.json created+790
...@@ -0,0 +1,790 @@
1{
2 "source": ".plans/16a-pr89-phase0-canonical-triage.json",
3 "architecture_assumption": "Provider runtime activity is projected and consumed via orchestration.domainEvent; no separate providers.event push channel is expected.",
4 "summary": {
5 "total_canonical_reviewed": 58,
6 "valid": 34,
7 "partially_valid": 19,
8 "invalid": 5
9 },
10 "records": [
11 {
12 "canonical_id": "C001",
13 "verdict": "valid",
14 "rationale": "Append/project/receipt are non-atomic; retry can duplicate events.",
15 "severity": "High",
16 "area": "Event ordering and state consistency",
17 "path": "apps/server/src/orchestration/Layers/OrchestrationEngine.ts",
18 "line": 96,
19 "title": "Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
20 "thread_ids": [
21 "PRRT_kwDORLtfbc5wzp4T"
22 ]
23 },
24 {
25 "canonical_id": "C002",
26 "verdict": "valid",
27 "rationale": "Ingestion worker loop can terminate on unhandled processEvent failure.",
28 "severity": "High",
29 "area": "Runtime resilience and failure handling",
30 "path": "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts",
31 "line": 333,
32 "title": "A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber.",
33 "thread_ids": [
34 "PRRT_kwDORLtfbc5wj4cH",
35 "PRRT_kwDORLtfbc5wnWwF",
36 "PRRT_kwDORLtfbc5wyTaP",
37 "PRRT_kwDORLtfbc5wzliw",
38 "PRRT_kwDORLtfbc5w0_g3",
39 "PRRT_kwDORLtfbc5w1HGT"
40 ]
41 },
42 {
43 "canonical_id": "C003",
44 "verdict": "valid",
45 "rationale": "Upgrade reject writes then destroys socket without defensive error listener.",
46 "severity": "High",
47 "area": "WebSocket robustness",
48 "path": "apps/server/src/wsServer.ts",
49 "line": 75,
50 "title": "Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake.",
51 "thread_ids": [
52 "PRRT_kwDORLtfbc5v-cf4"
53 ]
54 },
55 {
56 "canonical_id": "C006",
57 "verdict": "partially-valid",
58 "rationale": "Node 24 makes this work, but script still relies on runtime-specific TS execution.",
59 "severity": "Medium",
60 "area": "Build/runtime portability",
61 "path": "apps/server/package.json",
62 "line": 14,
63 "title": "Build script runs TypeScript file with bare node",
64 "thread_ids": [
65 "PRRT_kwDORLtfbc5wxsO-"
66 ]
67 },
68 {
69 "canonical_id": "C007",
70 "verdict": "partially-valid",
71 "rationale": "Same as C006 (duplicate concern).",
72 "severity": "Medium",
73 "area": "Build/runtime portability",
74 "path": "apps/server/package.json",
75 "line": 14,
76 "title": "Build script uses `node` to run `.ts` file directly",
77 "thread_ids": [
78 "PRRT_kwDORLtfbc5wiLNS",
79 "PRRT_kwDORLtfbc5wj5fl"
80 ]
81 },
82 {
83 "canonical_id": "C008",
84 "verdict": "partially-valid",
85 "rationale": "Normalization inconsistency remains, but upstream callsites now mostly canonicalize inputs.",
86 "severity": "Medium",
87 "area": "Checkpointing correctness",
88 "path": "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
89 "line": 94,
90 "title": "Inconsistent input normalization across CheckpointStore methods",
91 "thread_ids": [
92 "PRRT_kwDORLtfbc5widJw",
93 "PRRT_kwDORLtfbc5wnWv_",
94 "PRRT_kwDORLtfbc5w0_g7",
95 "PRRT_kwDORLtfbc5w1C36"
96 ]
97 },
98 {
99 "canonical_id": "C009",
100 "verdict": "valid",
101 "rationale": "Braced rename parsing still breaks paths like src/{old => new}/file.ts.",
102 "severity": "Medium",
103 "area": "Edge-case parsing/platform behavior",
104 "path": "apps/server/src/git/Layers/GitCore.ts",
105 "line": 41,
106 "title": "Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after ` => ` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
107 "thread_ids": [
108 "PRRT_kwDORLtfbc5w1CxT"
109 ]
110 },
111 {
112 "canonical_id": "C010",
113 "verdict": "valid",
114 "rationale": "ENOENT for missing keybindings config still not handled as empty/default.",
115 "severity": "Medium",
116 "area": "Edge-case parsing/platform behavior",
117 "path": "apps/server/src/keybindings.ts",
118 "line": 418,
119 "title": "`loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
120 "thread_ids": [
121 "PRRT_kwDORLtfbc5wxvIJ"
122 ]
123 },
124 {
125 "canonical_id": "C011",
126 "verdict": "valid",
127 "rationale": "Template-literal interpolation of Symbol still throws.",
128 "severity": "Medium",
129 "area": "Other",
130 "path": "apps/server/src/orchestration/Errors.ts",
131 "line": 117,
132 "title": "Template literal `${cause}` throws if `cause` is a `Symbol`. Consider using `String(cause)` instead.",
133 "thread_ids": [
134 "PRRT_kwDORLtfbc5wBFv4"
135 ]
136 },
137 {
138 "canonical_id": "C012",
139 "verdict": "valid",
140 "rationale": "Revert completion dispatch remains forked; state consistency window remains.",
141 "severity": "Medium",
142 "area": "Runtime resilience and failure handling",
143 "path": "apps/server/src/orchestration/Layers/CheckpointReactor.ts",
144 "line": 542,
145 "title": "Forked revert dispatch risks read model inconsistency",
146 "thread_ids": [
147 "PRRT_kwDORLtfbc5whszW",
148 "PRRT_kwDORLtfbc5wyTaS",
149 "PRRT_kwDORLtfbc5wzli0",
150 "PRRT_kwDORLtfbc5w0_g4",
151 "PRRT_kwDORLtfbc5w1HGX"
152 ]
153 },
154 {
155 "canonical_id": "C013",
156 "verdict": "valid",
157 "rationale": "Persisted event can outpace in-memory projection on mid-flight failure.",
158 "severity": "Medium",
159 "area": "Event ordering and state consistency",
160 "path": "apps/server/src/orchestration/Layers/OrchestrationEngine.ts",
161 "line": 99,
162 "title": "If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures.",
163 "thread_ids": [
164 "PRRT_kwDORLtfbc5whtrM"
165 ]
166 },
167 {
168 "canonical_id": "C014",
169 "verdict": "partially-valid",
170 "rationale": "Broad catch may be intentional for liveness; correctness concern is contextual.",
171 "severity": "Medium",
172 "area": "Event ordering and state consistency",
173 "path": "apps/server/src/orchestration/Layers/OrchestrationEngine.ts",
174 "line": 144,
175 "title": "Engine error handler catches all errors including non-invariant ones",
176 "thread_ids": [
177 "PRRT_kwDORLtfbc5wkPaJ"
178 ]
179 },
180 {
181 "canonical_id": "C015",
182 "verdict": "partially-valid",
183 "rationale": "Message fallback retention issue is real, but prior FK-violation claim is overstated.",
184 "severity": "Medium",
185 "area": "Event ordering and state consistency",
186 "path": "apps/server/src/orchestration/Layers/ProjectionPipeline.ts",
187 "line": 99,
188 "title": "The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
189 "thread_ids": [
190 "PRRT_kwDORLtfbc5whxJO"
191 ]
192 },
193 {
194 "canonical_id": "C016",
195 "verdict": "valid",
196 "rationale": "Pending turn-start map is in-memory only and not rebuilt on bootstrap.",
197 "severity": "Medium",
198 "area": "Event ordering and state consistency",
199 "path": "apps/server/src/orchestration/Layers/ProjectionPipeline.ts",
200 "line": 490,
201 "title": "The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
202 "thread_ids": [
203 "PRRT_kwDORLtfbc5wxvH8"
204 ]
205 },
206 {
207 "canonical_id": "C017",
208 "verdict": "partially-valid",
209 "rationale": "Snapshot sequence can under-report due to extra projectors, but replay impact is lower now.",
210 "severity": "Medium",
211 "area": "Checkpointing correctness",
212 "path": "apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts",
213 "line": 71,
214 "title": "`REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
215 "thread_ids": [
216 "PRRT_kwDORLtfbc5wiLhQ"
217 ]
218 },
219 {
220 "canonical_id": "C018",
221 "verdict": "valid",
222 "rationale": "handledTurnStartKeys still grows without pruning.",
223 "severity": "Medium",
224 "area": "Memory/resource growth",
225 "path": "apps/server/src/orchestration/Layers/ProviderCommandReactor.ts",
226 "line": 84,
227 "title": "Unbounded memory growth in turn start deduplication set",
228 "thread_ids": [
229 "PRRT_kwDORLtfbc5whszQ",
230 "PRRT_kwDORLtfbc5wl2A8",
231 "PRRT_kwDORLtfbc5wyTaT",
232 "PRRT_kwDORLtfbc5wzliz",
233 "PRRT_kwDORLtfbc5w0_g-",
234 "PRRT_kwDORLtfbc5w1HGW"
235 ]
236 },
237 {
238 "canonical_id": "C019",
239 "verdict": "partially-valid",
240 "rationale": "SessionId-only routing can misassociate events under races/rebinds.",
241 "severity": "Medium",
242 "area": "Runtime resilience and failure handling",
243 "path": "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts",
244 "line": 178,
245 "title": "ProviderRuntimeIngestion processes events for wrong thread on race",
246 "thread_ids": [
247 "PRRT_kwDORLtfbc5wkPaL"
248 ]
249 },
250 {
251 "canonical_id": "C020",
252 "verdict": "partially-valid",
253 "rationale": "Duplicate complete dispatch exists; downstream impact often idempotent.",
254 "severity": "Medium",
255 "area": "Runtime resilience and failure handling",
256 "path": "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts",
257 "line": 266,
258 "title": "On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID\u2014including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop.",
259 "thread_ids": [
260 "PRRT_kwDORLtfbc5w1GPr"
261 ]
262 },
263 {
264 "canonical_id": "C021",
265 "verdict": "invalid",
266 "rationale": "Stale-eventId claim no longer applies; eventId is regenerated per event.",
267 "severity": "Medium",
268 "area": "Other",
269 "path": "apps/server/src/orchestration/decider.ts",
270 "line": 27,
271 "title": "Shared mutable default metadata object causes stale eventId",
272 "thread_ids": [
273 "PRRT_kwDORLtfbc5wkPaA"
274 ]
275 },
276 {
277 "canonical_id": "C022",
278 "verdict": "valid",
279 "rationale": "fish PATH formatting risk still exists in os-jank path recovery.",
280 "severity": "Medium",
281 "area": "Edge-case parsing/platform behavior",
282 "path": "apps/server/src/os-jank.ts",
283 "line": 10,
284 "title": "Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning.",
285 "thread_ids": [
286 "PRRT_kwDORLtfbc5wkRZM"
287 ]
288 },
289 {
290 "canonical_id": "C023",
291 "verdict": "valid",
292 "rationale": "-ilc shell invocation can pollute captured PATH output.",
293 "severity": "Medium",
294 "area": "Edge-case parsing/platform behavior",
295 "path": "apps/server/src/os-jank.ts",
296 "line": 10,
297 "title": "Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output.",
298 "thread_ids": [
299 "PRRT_kwDORLtfbc5wj4cM"
300 ]
301 },
302 {
303 "canonical_id": "C024",
304 "verdict": "partially-valid",
305 "rationale": "prepare() still outside managed effect boundary; failure handling risk remains.",
306 "severity": "Medium",
307 "area": "Other",
308 "path": "apps/server/src/persistence/NodeSqliteClient.ts",
309 "line": 145,
310 "title": "In `executeUnprepared`, preparing outside an effect and not closing the statement can cause uncaught errors and leaks. Suggest wrapping with `Effect.acquireUseRelease`: prepare in acquire (error-safe) and `finalize` in release.",
311 "thread_ids": [
312 "PRRT_kwDORLtfbc5whxX9"
313 ]
314 },
315 {
316 "canonical_id": "C025",
317 "verdict": "invalid",
318 "rationale": "No longer duplicated; checkpoint ref helper now centralized.",
319 "severity": "Medium",
320 "area": "Checkpointing correctness",
321 "path": "apps/server/src/wsServer.ts",
322 "line": 128,
323 "title": "Duplicated checkpoint ref computation across two files",
324 "thread_ids": [
325 "PRRT_kwDORLtfbc5wvwag"
326 ]
327 },
328 {
329 "canonical_id": "C026",
330 "verdict": "valid",
331 "rationale": "runPromise result still not caught; rejection can surface unhandled.",
332 "severity": "Medium",
333 "area": "WebSocket robustness",
334 "path": "apps/server/src/wsServer.ts",
335 "line": 545,
336 "title": "Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails.",
337 "thread_ids": [
338 "PRRT_kwDORLtfbc5wj4cE"
339 ]
340 },
341 {
342 "canonical_id": "C027",
343 "verdict": "valid",
344 "rationale": "Same unhandled rejection path remains in WS message handler.",
345 "severity": "Medium",
346 "area": "Runtime resilience and failure handling",
347 "path": "apps/server/src/wsServer.ts",
348 "line": 545,
349 "title": "WS message handler can cause unhandled promise rejection",
350 "thread_ids": [
351 "PRRT_kwDORLtfbc5wyTaW",
352 "PRRT_kwDORLtfbc5wzli3"
353 ]
354 },
355 {
356 "canonical_id": "C028",
357 "verdict": "partially-valid",
358 "rationale": "Optimistic local+server dual update is intentional but can temporarily diverge.",
359 "severity": "Medium",
360 "area": "Other",
361 "path": "apps/web/src/components/BranchToolbar.tsx",
362 "line": 102,
363 "title": "Branch sync dispatches both server and stale local update",
364 "thread_ids": [
365 "PRRT_kwDORLtfbc5v-XCu"
366 ]
367 },
368 {
369 "canonical_id": "C029",
370 "verdict": "valid",
371 "rationale": "file URL decoding still double-decodes in one path.",
372 "severity": "Medium",
373 "area": "Edge-case parsing/platform behavior",
374 "path": "apps/web/src/markdown-links.ts",
375 "line": 105,
376 "title": "`parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null.",
377 "thread_ids": [
378 "PRRT_kwDORLtfbc5wnVsU"
379 ]
380 },
381 {
382 "canonical_id": "C030",
383 "verdict": "valid",
384 "rationale": "Scheme regex still misclassifies script.ts:10 as external scheme.",
385 "severity": "Medium",
386 "area": "Edge-case parsing/platform behavior",
387 "path": "apps/web/src/markdown-links.ts",
388 "line": 111,
389 "title": "`EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
390 "thread_ids": [
391 "PRRT_kwDORLtfbc5wnVsK"
392 ]
393 },
394 {
395 "canonical_id": "C031",
396 "verdict": "invalid",
397 "rationale": "Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary.",
398 "severity": "Medium",
399 "area": "Event ordering and state consistency",
400 "path": "apps/web/src/session-logic.ts",
401 "line": 127,
402 "title": "Revert uses wrong turn count from positional inference",
403 "thread_ids": [
404 "PRRT_kwDORLtfbc5v9SCp"
405 ]
406 },
407 {
408 "canonical_id": "C032",
409 "verdict": "valid",
410 "rationale": "Dummy workflow marker comment is still present in docs.",
411 "severity": "Low",
412 "area": "Other",
413 "path": "AGENTS.md",
414 "line": 45,
415 "title": "Dummy workflow marker accidentally committed to docs",
416 "thread_ids": [
417 "PRRT_kwDORLtfbc5wlYgj",
418 "PRRT_kwDORLtfbc5w1C4H"
419 ]
420 },
421 {
422 "canonical_id": "C033",
423 "verdict": "partially-valid",
424 "rationale": "Original claim overstated; some errors used, others appear unused.",
425 "severity": "Low",
426 "area": "Checkpointing correctness",
427 "path": "apps/server/src/checkpointing/Errors.ts",
428 "line": 51,
429 "title": "Three error classes defined but never instantiated anywhere",
430 "thread_ids": [
431 "PRRT_kwDORLtfbc5wlYgo"
432 ]
433 },
434 {
435 "canonical_id": "C034",
436 "verdict": "valid",
437 "rationale": "CheckpointInvariantError remains redundantly included in service union.",
438 "severity": "Low",
439 "area": "Checkpointing correctness",
440 "path": "apps/server/src/checkpointing/Errors.ts",
441 "line": 79,
442 "title": "Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type",
443 "thread_ids": [
444 "PRRT_kwDORLtfbc5wj5fn"
445 ]
446 },
447 {
448 "canonical_id": "C035",
449 "verdict": "valid",
450 "rationale": "Same as C034.",
451 "severity": "Low",
452 "area": "Checkpointing correctness",
453 "path": "apps/server/src/checkpointing/Errors.ts",
454 "line": 79,
455 "title": "Redundant error type in CheckpointServiceError union definition",
456 "thread_ids": [
457 "PRRT_kwDORLtfbc5wlYgs",
458 "PRRT_kwDORLtfbc5wxsO6",
459 "PRRT_kwDORLtfbc5w1C4B"
460 ]
461 },
462 {
463 "canonical_id": "C036",
464 "verdict": "invalid",
465 "rationale": "No longer duplicated; single production source via Refs.ts.",
466 "severity": "Low",
467 "area": "Checkpointing correctness",
468 "path": "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
469 "line": 284,
470 "title": "Duplicate `checkpointRefForThreadTurn` function in two production files",
471 "thread_ids": [
472 "PRRT_kwDORLtfbc5wiqFX"
473 ]
474 },
475 {
476 "canonical_id": "C037",
477 "verdict": "partially-valid",
478 "rationale": "Callback cleanup missing, but practical exposure is low in one-shot startup path.",
479 "severity": "Low",
480 "area": "Other",
481 "path": "apps/server/src/config.ts",
482 "line": 41,
483 "title": "`Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
484 "thread_ids": [
485 "PRRT_kwDORLtfbc5wj4cO"
486 ]
487 },
488 {
489 "canonical_id": "C038",
490 "verdict": "valid",
491 "rationale": "Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters.",
492 "severity": "Low",
493 "area": "Edge-case parsing/platform behavior",
494 "path": "apps/server/src/git/Layers/CodexTextGeneration.ts",
495 "line": 136,
496 "title": "Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
497 "thread_ids": [
498 "PRRT_kwDORLtfbc5w1GPo"
499 ]
500 },
501 {
502 "canonical_id": "C039",
503 "verdict": "partially-valid",
504 "rationale": "Parser/encoder mismatch remains, but encoder path currently low-use.",
505 "severity": "Low",
506 "area": "Edge-case parsing/platform behavior",
507 "path": "apps/server/src/keybindings.ts",
508 "line": 352,
509 "title": "The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes(\"+\")` returns true for the literal `+` key. Consider checking `shortcut.key === \"+\"` separately and encoding it as `\"space\"` style (e.g., a special token), or adjusting the condition to allow the single `+` character. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
510 "thread_ids": [
511 "PRRT_kwDORLtfbc5wxvIB"
512 ]
513 },
514 {
515 "canonical_id": "C040",
516 "verdict": "valid",
517 "rationale": "upsertKeybindingRule read-modify-write remains race-prone.",
518 "severity": "Low",
519 "area": "Edge-case parsing/platform behavior",
520 "path": "apps/server/src/keybindings.ts",
521 "line": 488,
522 "title": "`upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
523 "thread_ids": [
524 "PRRT_kwDORLtfbc5wxvIA"
525 ]
526 },
527 {
528 "canonical_id": "C041",
529 "verdict": "valid",
530 "rationale": "Same Symbol interpolation hazard as C011 remains.",
531 "severity": "Low",
532 "area": "Other",
533 "path": "apps/server/src/orchestration/Errors.ts",
534 "line": 126,
535 "title": "Template literal interpolation throws `TypeError` if `cause` is a `Symbol`. Consider using `String(cause)` for safe coercion.",
536 "thread_ids": [
537 "PRRT_kwDORLtfbc5wBFWs"
538 ]
539 },
540 {
541 "canonical_id": "C042",
542 "verdict": "partially-valid",
543 "rationale": "Duplication exists but one instance is variant logic, so impact is moderate.",
544 "severity": "Low",
545 "area": "Runtime resilience and failure handling",
546 "path": "apps/server/src/orchestration/Layers/CheckpointReactor.ts",
547 "line": 62,
548 "title": "Duplicated `resolveThreadWorkspaceCwd` across three files",
549 "thread_ids": [
550 "PRRT_kwDORLtfbc5wzli2"
551 ]
552 },
553 {
554 "canonical_id": "C043",
555 "verdict": "valid",
556 "rationale": "Workspace CWD resolution duplication still present across modules.",
557 "severity": "Low",
558 "area": "Runtime resilience and failure handling",
559 "path": "apps/server/src/orchestration/Layers/CheckpointReactor.ts",
560 "line": 62,
561 "title": "Duplicated workspace CWD resolution logic across reactor modules",
562 "thread_ids": [
563 "PRRT_kwDORLtfbc5wnWwM",
564 "PRRT_kwDORLtfbc5w1C3-",
565 "PRRT_kwDORLtfbc5w1HGZ"
566 ]
567 },
568 {
569 "canonical_id": "C044",
570 "verdict": "partially-valid",
571 "rationale": "Errors are swallowed to empty diff with warning; not fully silent but still lossy.",
572 "severity": "Low",
573 "area": "Runtime resilience and failure handling",
574 "path": "apps/server/src/orchestration/Layers/CheckpointReactor.ts",
575 "line": 274,
576 "title": "Checkpoint reactor swallows diff errors silently for `turn.completed`",
577 "thread_ids": [
578 "PRRT_kwDORLtfbc5wkPaO"
579 ]
580 },
581 {
582 "canonical_id": "C045",
583 "verdict": "valid",
584 "rationale": "truncateDetail still overshoots limit.",
585 "severity": "Low",
586 "area": "Runtime resilience and failure handling",
587 "path": "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts",
588 "line": 29,
589 "title": "`truncateDetail` slices to `limit - 1` then appends `\"...\"` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
590 "thread_ids": [
591 "PRRT_kwDORLtfbc5wzp4R"
592 ]
593 },
594 {
595 "canonical_id": "C046",
596 "verdict": "valid",
597 "rationale": "latestMessageIdByTurnKey still unused/unpruned in per-turn clear path.",
598 "severity": "Low",
599 "area": "Runtime resilience and failure handling",
600 "path": "apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts",
601 "line": 133,
602 "title": "`latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries\u2014only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
603 "thread_ids": [
604 "PRRT_kwDORLtfbc5wxvIQ"
605 ]
606 },
607 {
608 "canonical_id": "C047",
609 "verdict": "valid",
610 "rationale": "Decode and SQL errors still collapsed into one persistence error kind.",
611 "severity": "Low",
612 "area": "Other",
613 "path": "apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts",
614 "line": 75,
615 "title": "`SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
616 "thread_ids": [
617 "PRRT_kwDORLtfbc5wiaR-"
618 ]
619 },
620 {
621 "canonical_id": "C048",
622 "verdict": "partially-valid",
623 "rationale": "Unsafe run() result cast remains; runtime risk depends on raw-result consumers.",
624 "severity": "Low",
625 "area": "Other",
626 "path": "apps/server/src/persistence/NodeSqliteClient.ts",
627 "line": 99,
628 "title": "`statement.run()` returns a `RunResult` object, not an array. Casting it to `ReadonlyArray<any>` will cause runtime errors when consumers call array methods. Consider wrapping the result (e.g., `[result]`) or returning a properly typed object.",
629 "thread_ids": [
630 "PRRT_kwDORLtfbc5wio-r"
631 ]
632 },
633 {
634 "canonical_id": "C049",
635 "verdict": "valid",
636 "rationale": "JSON.stringify(cause) may return undefined despite string expectations.",
637 "severity": "Low",
638 "area": "Other",
639 "path": "apps/server/src/provider/Layers/ProviderService.ts",
640 "line": 59,
641 "title": "`JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback.",
642 "thread_ids": [
643 "PRRT_kwDORLtfbc5wnVsI"
644 ]
645 },
646 {
647 "canonical_id": "C050",
648 "verdict": "valid",
649 "rationale": "ProviderSessionDirectory upsert remains read-merge-write without concurrency control.",
650 "severity": "Low",
651 "area": "Other",
652 "path": "apps/server/src/provider/Layers/ProviderSessionDirectory.ts",
653 "line": 94,
654 "title": "The read-modify-write pattern (`getBySessionId` \u2192 merge \u2192 `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
655 "thread_ids": [
656 "PRRT_kwDORLtfbc5wiLhY"
657 ]
658 },
659 {
660 "canonical_id": "C051",
661 "verdict": "partially-valid",
662 "rationale": "Null-clearing issue is real for providerThreadId; adapterKey part overstated.",
663 "severity": "Low",
664 "area": "Other",
665 "path": "apps/server/src/provider/Layers/ProviderSessionDirectory.ts",
666 "line": 119,
667 "title": "Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
668 "thread_ids": [
669 "PRRT_kwDORLtfbc5wxvH9"
670 ]
671 },
672 {
673 "canonical_id": "C052",
674 "verdict": "valid",
675 "rationale": "Data callback may race before processHandle assignment.",
676 "severity": "Low",
677 "area": "Other",
678 "path": "apps/server/src/terminal/Layers/BunPTY.ts",
679 "line": 97,
680 "title": "Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
681 "thread_ids": [
682 "PRRT_kwDORLtfbc5w1CxE"
683 ]
684 },
685 {
686 "canonical_id": "C053",
687 "verdict": "valid",
688 "rationale": "Still uses write+destroy rather than end() for rejection response.",
689 "severity": "Low",
690 "area": "WebSocket robustness",
691 "path": "apps/server/src/wsServer.ts",
692 "line": 83,
693 "title": "Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection.",
694 "thread_ids": [
695 "PRRT_kwDORLtfbc5v-WPD"
696 ]
697 },
698 {
699 "canonical_id": "C054",
700 "verdict": "valid",
701 "rationale": "Array chunk UTF-8 decode remains vulnerable to split multibyte corruption.",
702 "severity": "Low",
703 "area": "WebSocket robustness",
704 "path": "apps/server/src/wsServer.ts",
705 "line": 104,
706 "title": "When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString(\"utf8\")`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
707 "thread_ids": [
708 "PRRT_kwDORLtfbc5whtrR"
709 ]
710 },
711 {
712 "canonical_id": "C055",
713 "verdict": "invalid",
714 "rationale": "No longer duplicated; helper is centralized.",
715 "severity": "Low",
716 "area": "Checkpointing correctness",
717 "path": "apps/server/src/wsServer.ts",
718 "line": 128,
719 "title": "Duplicate `checkpointRefForThreadTurn` function across files",
720 "thread_ids": [
721 "PRRT_kwDORLtfbc5wkPaG"
722 ]
723 },
724 {
725 "canonical_id": "C056",
726 "verdict": "partially-valid",
727 "rationale": "Bug pattern exists, but current callsites mostly avoid triggering it.",
728 "severity": "Low",
729 "area": "Other",
730 "path": "apps/web/src/components/ui/sidebar.tsx",
731 "line": 114,
732 "title": "When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists.",
733 "thread_ids": [
734 "PRRT_kwDORLtfbc5wxvIq"
735 ]
736 },
737 {
738 "canonical_id": "C057",
739 "verdict": "valid",
740 "rationale": "Resizable object recreation still retriggers effect/storage reads.",
741 "severity": "Low",
742 "area": "Other",
743 "path": "apps/web/src/routes/_chat.$threadId.tsx",
744 "line": 105,
745 "title": "The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
746 "thread_ids": [
747 "PRRT_kwDORLtfbc5wyWz4"
748 ]
749 },
750 {
751 "canonical_id": "C058",
752 "verdict": "valid",
753 "rationale": "Number(null) -> 0 path still forces min width on initial load.",
754 "severity": "Low",
755 "area": "Other",
756 "path": "apps/web/src/routes/_chat.$threadId.tsx",
757 "line": 122,
758 "title": "When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
759 "thread_ids": [
760 "PRRT_kwDORLtfbc5wnVsX"
761 ]
762 },
763 {
764 "canonical_id": "C059",
765 "verdict": "partially-valid",
766 "rationale": "Transport _tag override risk exists but current callsites are constrained.",
767 "severity": "Low",
768 "area": "WebSocket robustness",
769 "path": "apps/web/src/wsTransport.ts",
770 "line": 59,
771 "title": "Suggestion: don\u2019t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object.",
772 "thread_ids": [
773 "PRRT_kwDORLtfbc5whtrN"
774 ]
775 },
776 {
777 "canonical_id": "C060",
778 "verdict": "valid",
779 "rationale": "Schema still cannot express null clear for defaultModel patch.",
780 "severity": "Low",
781 "area": "Other",
782 "path": "packages/contracts/src/orchestration.ts",
783 "line": 253,
784 "title": "`defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means \"no change\" in patch semantics. <details> <summary>\ud83d\ude80 Reply \"<strong>fix it for me</strong>\" or copy this <strong>AI Prompt</strong> for your agent:</summary>",
785 "thread_ids": [
786 "PRRT_kwDORLtfbc5whxJC"
787 ]
788 }
789 ]
790}
.plans/16b-pr89-architecture-validity-audit.md created+125
...@@ -0,0 +1,125 @@
1# PR #89 Post-Rewrite Validity Audit
2
3Architecture baseline used for this audit:
4- Provider runtime activity is projected into orchestration domain events.
5- Web client consumes `orchestration.domainEvent`; no separate `providers.event` channel is expected.
6
7## Summary
8
9- Canonical items reviewed: 58
10- Valid: 34
11- Partially valid: 19
12- Invalid (stale/no longer applicable): 5
13
14## Invalid Items To Close
15
16- C021 [Medium] Shared mutable default metadata object causes stale eventId (apps/server/src/orchestration/decider.ts:27)
17 - Reason: Stale-eventId claim no longer applies; eventId is regenerated per event.
18 - Threads: PRRT_kwDORLtfbc5wkPaA
19- C025 [Medium] Duplicated checkpoint ref computation across two files (apps/server/src/wsServer.ts:128)
20 - Reason: No longer duplicated; checkpoint ref helper now centralized.
21 - Threads: PRRT_kwDORLtfbc5wvwag
22- C031 [Medium] Revert uses wrong turn count from positional inference (apps/web/src/session-logic.ts:127)
23 - Reason: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary.
24 - Threads: PRRT_kwDORLtfbc5v9SCp
25- C036 [Low] Duplicate `checkpointRefForThreadTurn` function in two production files (apps/server/src/checkpointing/Layers/CheckpointStore.ts:284)
26 - Reason: No longer duplicated; single production source via Refs.ts.
27 - Threads: PRRT_kwDORLtfbc5wiqFX
28- C055 [Low] Duplicate `checkpointRefForThreadTurn` function across files (apps/server/src/wsServer.ts:128)
29 - Reason: No longer duplicated; helper is centralized.
30 - Threads: PRRT_kwDORLtfbc5wkPaG
31
32## Partially Valid Items (de-scope or lower priority)
33
34- C006 [Medium] Build script runs TypeScript file with bare node (apps/server/package.json:14)
35 - Note: Node 24 makes this work, but script still relies on runtime-specific TS execution.
36- C007 [Medium] Build script uses `node` to run `.ts` file directly (apps/server/package.json:14)
37 - Note: Same as C006 (duplicate concern).
38- C008 [Medium] Inconsistent input normalization across CheckpointStore methods (apps/server/src/checkpointing/Layers/CheckpointStore.ts:94)
39 - Note: Normalization inconsistency remains, but upstream callsites now mostly canonicalize inputs.
40- C014 [Medium] Engine error handler catches all errors including non-invariant ones (apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144)
41 - Note: Broad catch may be intentional for liveness; correctness concern is contextual.
42- C015 [Medium] The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionPipeline.ts:99)
43 - Note: Message fallback retention issue is real, but prior FK-violation claim is overstated.
44- C017 [Medium] `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71)
45 - Note: Snapshot sequence can under-report due to extra projectors, but replay impact is lower now.
46- C019 [Medium] ProviderRuntimeIngestion processes events for wrong thread on race (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178)
47 - Note: SessionId-only routing can misassociate events under races/rebinds.
48- C020 [Medium] On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266)
49 - Note: Duplicate complete dispatch exists; downstream impact often idempotent.
50- C024 [Medium] In `executeUnprepared`, preparing outside an effect and not closing the statement can cause uncaught errors and leaks. Suggest wrapping with `Effect.acquireUseRelease`: prepare in acquire (error-safe) and `finalize` in release. (apps/server/src/persistence/NodeSqliteClient.ts:145)
51 - Note: prepare() still outside managed effect boundary; failure handling risk remains.
52- C028 [Medium] Branch sync dispatches both server and stale local update (apps/web/src/components/BranchToolbar.tsx:102)
53 - Note: Optimistic local+server dual update is intentional but can temporarily diverge.
54- C033 [Low] Three error classes defined but never instantiated anywhere (apps/server/src/checkpointing/Errors.ts:51)
55 - Note: Original claim overstated; some errors used, others appear unused.
56- C037 [Low] `Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/config.ts:41)
57 - Note: Callback cleanup missing, but practical exposure is low in one-shot startup path.
58- C039 [Low] The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/keybindings.ts:352)
59 - Note: Parser/encoder mismatch remains, but encoder path currently low-use.
60- C042 [Low] Duplicated `resolveThreadWorkspaceCwd` across three files (apps/server/src/orchestration/Layers/CheckpointReactor.ts:62)
61 - Note: Duplication exists but one instance is variant logic, so impact is moderate.
62- C044 [Low] Checkpoint reactor swallows diff errors silently for `turn.completed` (apps/server/src/orchestration/Layers/CheckpointReactor.ts:274)
63 - Note: Errors are swallowed to empty diff with warning; not fully silent but still lossy.
64- C048 [Low] `statement.run()` returns a `RunResult` object, not an array. Casting it to `ReadonlyArray<any>` will cause runtime errors when consumers call array methods. Consider wrapping the result (e.g., `[result]`) or returning a properly typed object. (apps/server/src/persistence/NodeSqliteClient.ts:99)
65 - Note: Unsafe run() result cast remains; runtime risk depends on raw-result consumers.
66- C051 [Low] Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/provider/Layers/ProviderSessionDirectory.ts:119)
67 - Note: Null-clearing issue is real for providerThreadId; adapterKey part overstated.
68- C056 [Low] When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists. (apps/web/src/components/ui/sidebar.tsx:114)
69 - Note: Bug pattern exists, but current callsites mostly avoid triggering it.
70- C059 [Low] Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. (apps/web/src/wsTransport.ts:59)
71 - Note: Transport _tag override risk exists but current callsites are constrained.
72
73## Valid Items (actionable unless marked completed)
74
75- C001 [High] Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96)
76- C002 [High] A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333)
77 - Status: Completed in Phase 1.
78- C003 [High] Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. (apps/server/src/wsServer.ts:75)
79- C009 [Medium] Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after ` => ` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/git/Layers/GitCore.ts:41)
80- C010 [Medium] `loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/keybindings.ts:418)
81- C011 [Medium] Template literal `${cause}` throws if `cause` is a `Symbol`. Consider using `String(cause)` instead. (apps/server/src/orchestration/Errors.ts:117)
82- C012 [Medium] Forked revert dispatch risks read model inconsistency (apps/server/src/orchestration/Layers/CheckpointReactor.ts:542)
83 - Status: Completed in Phase 1.
84- C013 [Medium] If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures. (apps/server/src/orchestration/Layers/OrchestrationEngine.ts:99)
85- C016 [Medium] The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490)
86- C018 [Medium] Unbounded memory growth in turn start deduplication set (apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:84)
87- C022 [Medium] Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning. (apps/server/src/os-jank.ts:10)
88- C023 [Medium] Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output. (apps/server/src/os-jank.ts:10)
89- C026 [Medium] Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. (apps/server/src/wsServer.ts:545)
90 - Status: Completed in Phase 1.
91- C027 [Medium] WS message handler can cause unhandled promise rejection (apps/server/src/wsServer.ts:545)
92 - Status: Completed in Phase 1.
93- C029 [Medium] `parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null. (apps/web/src/markdown-links.ts:105)
94- C030 [Medium] `EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/web/src/markdown-links.ts:111)
95- C032 [Low] Dummy workflow marker accidentally committed to docs (AGENTS.md:45)
96- C034 [Low] Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type (apps/server/src/checkpointing/Errors.ts:79)
97- C035 [Low] Redundant error type in CheckpointServiceError union definition (apps/server/src/checkpointing/Errors.ts:79)
98- C038 [Low] Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/git/Layers/CodexTextGeneration.ts:136)
99- C040 [Low] `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/keybindings.ts:488)
100- C041 [Low] Template literal interpolation throws `TypeError` if `cause` is a `Symbol`. Consider using `String(cause)` for safe coercion. (apps/server/src/orchestration/Errors.ts:126)
101- C043 [Low] Duplicated workspace CWD resolution logic across reactor modules (apps/server/src/orchestration/Layers/CheckpointReactor.ts:62)
102- C045 [Low] `truncateDetail` slices to `limit - 1` then appends `"..."` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:29)
103- C046 [Low] `latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries—only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:133)
104- C047 [Low] `SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts:75)
105- C049 [Low] `JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback. (apps/server/src/provider/Layers/ProviderService.ts:59)
106- C050 [Low] The read-modify-write pattern (`getBySessionId` → merge → `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/provider/Layers/ProviderSessionDirectory.ts:94)
107- C052 [Low] Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/terminal/Layers/BunPTY.ts:97)
108- C053 [Low] Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. (apps/server/src/wsServer.ts:83)
109- C054 [Low] When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString("utf8")`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/server/src/wsServer.ts:104)
110- C057 [Low] The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/web/src/routes/_chat.$threadId.tsx:105)
111- C058 [Low] When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (apps/web/src/routes/_chat.$threadId.tsx:122)
112- C060 [Low] `defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means "no change" in patch semantics. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> (packages/contracts/src/orchestration.ts:253)
113
114## By Area
115
116- Build/runtime portability: valid=0, partially-valid=2, invalid=0
117- Checkpointing correctness: valid=2, partially-valid=3, invalid=3
118- Edge-case parsing/platform behavior: valid=8, partially-valid=1, invalid=0
119- Event ordering and state consistency: valid=3, partially-valid=2, invalid=1
120- Memory/resource growth: valid=1, partially-valid=0, invalid=0
121- Other: valid=10, partially-valid=6, invalid=1
122- Runtime resilience and failure handling: valid=6, partially-valid=4, invalid=0
123- WebSocket robustness: valid=4, partially-valid=1, invalid=0
124
125Machine-readable mapping: `.plans/16b-pr89-architecture-validity-audit.json`.
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+165-4
...@@ -42,10 +42,12 @@ const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.make...@@ -42,10 +42,12 @@ const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.make
42const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);42const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
43const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);43const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4444
45function createProviderServiceHarness(cwd: string, hasSession = true) {45function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd = cwd) {
46 const now = new Date().toISOString();46 const now = new Date().toISOString();
47 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());47 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
48 const rollbackConversation = vi.fn(() => Effect.void);48 const rollbackConversation = vi.fn(
49 (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void,
50 );
4951
50 const unsupported = <A>() =>52 const unsupported = <A>() =>
51 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;53 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
...@@ -57,7 +59,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) {...@@ -57,7 +59,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) {
57 provider: "codex",59 provider: "codex",
58 status: "ready",60 status: "ready",
59 threadId: asProviderThreadId("provider-thread-1"),61 threadId: asProviderThreadId("provider-thread-1"),
60 cwd,62 cwd: sessionCwd,
61 createdAt: now,63 createdAt: now,
62 updatedAt: now,64 updatedAt: now,
63 },65 },
...@@ -214,10 +216,15 @@ describe("CheckpointReactor", () => {...@@ -214,10 +216,15 @@ describe("CheckpointReactor", () => {
214 readonly seedFilesystemCheckpoints?: boolean;216 readonly seedFilesystemCheckpoints?: boolean;
215 readonly projectWorkspaceRoot?: string;217 readonly projectWorkspaceRoot?: string;
216 readonly threadWorktreePath?: string | null;218 readonly threadWorktreePath?: string | null;
219 readonly providerSessionCwd?: string;
217 }) {220 }) {
218 const cwd = createGitRepository();221 const cwd = createGitRepository();
219 tempDirs.push(cwd);222 tempDirs.push(cwd);
220 const provider = createProviderServiceHarness(cwd, options?.hasSession ?? true);223 const provider = createProviderServiceHarness(
224 cwd,
225 options?.hasSession ?? true,
226 options?.providerSessionCwd ?? cwd,
227 );
221 const orchestrationLayer = OrchestrationEngineLive.pipe(228 const orchestrationLayer = OrchestrationEngineLive.pipe(
222 Layer.provide(OrchestrationProjectionPipelineLive),229 Layer.provide(OrchestrationProjectionPipelineLive),
223 Layer.provide(OrchestrationEventStoreLive),230 Layer.provide(OrchestrationEventStoreLive),
...@@ -496,6 +503,65 @@ describe("CheckpointReactor", () => {...@@ -496,6 +503,65 @@ describe("CheckpointReactor", () => {
496 );503 );
497 });504 });
498505
506 it("continues processing runtime events after a single checkpoint runtime failure", async () => {
507 const nonRepositorySessionCwd = fs.mkdtempSync(
508 path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"),
509 );
510 tempDirs.push(nonRepositorySessionCwd);
511
512 const harness = await createHarness({
513 seedFilesystemCheckpoints: false,
514 providerSessionCwd: nonRepositorySessionCwd,
515 });
516 const createdAt = new Date().toISOString();
517
518 await Effect.runPromise(
519 harness.engine.dispatch({
520 type: "thread.session.set",
521 commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"),
522 threadId: ThreadId.makeUnsafe("thread-1"),
523 session: {
524 threadId: ThreadId.makeUnsafe("thread-1"),
525 status: "ready",
526 providerName: "codex",
527 providerSessionId: asSessionId("sess-1"),
528 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
529 activeTurnId: null,
530 lastError: null,
531 updatedAt: createdAt,
532 },
533 createdAt,
534 }),
535 );
536
537 harness.provider.emit({
538 type: "checkpoint.captured",
539 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
540 provider: "codex",
541 sessionId: asSessionId("sess-1"),
542 createdAt: new Date().toISOString(),
543 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
544 turnId: asTurnId("turn-runtime-failure"),
545 turnCount: 1,
546 status: "completed",
547 });
548
549 harness.provider.emit({
550 type: "turn.started",
551 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
552 provider: "codex",
553 sessionId: asSessionId("sess-1"),
554 createdAt: new Date().toISOString(),
555 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
556 turnId: asTurnId("turn-after-runtime-failure"),
557 });
558
559 await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0));
560 expect(
561 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)),
562 ).toBe(true);
563 });
564
499 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {565 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
500 const harness = await createHarness();566 const harness = await createHarness();
501 const createdAt = new Date().toISOString();567 const createdAt = new Date().toISOString();
...@@ -575,6 +641,101 @@ describe("CheckpointReactor", () => {...@@ -575,6 +641,101 @@ describe("CheckpointReactor", () => {
575 );641 );
576 });642 });
577643
644 it("processes consecutive revert requests with deterministic rollback sequencing", async () => {
645 const harness = await createHarness();
646 const createdAt = new Date().toISOString();
647
648 await Effect.runPromise(
649 harness.engine.dispatch({
650 type: "thread.session.set",
651 commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"),
652 threadId: ThreadId.makeUnsafe("thread-1"),
653 session: {
654 threadId: ThreadId.makeUnsafe("thread-1"),
655 status: "ready",
656 providerName: "codex",
657 providerSessionId: asSessionId("sess-1"),
658 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
659 activeTurnId: null,
660 lastError: null,
661 updatedAt: createdAt,
662 },
663 createdAt,
664 }),
665 );
666
667 await Effect.runPromise(
668 harness.engine.dispatch({
669 type: "thread.turn.diff.complete",
670 commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"),
671 threadId: ThreadId.makeUnsafe("thread-1"),
672 turnId: asTurnId("turn-1"),
673 completedAt: createdAt,
674 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
675 status: "ready",
676 files: [],
677 checkpointTurnCount: 1,
678 createdAt,
679 }),
680 );
681 await Effect.runPromise(
682 harness.engine.dispatch({
683 type: "thread.turn.diff.complete",
684 commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"),
685 threadId: ThreadId.makeUnsafe("thread-1"),
686 turnId: asTurnId("turn-2"),
687 completedAt: createdAt,
688 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
689 status: "ready",
690 files: [],
691 checkpointTurnCount: 2,
692 createdAt,
693 }),
694 );
695
696 await Effect.runPromise(
697 harness.engine.dispatch({
698 type: "thread.checkpoint.revert",
699 commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"),
700 threadId: ThreadId.makeUnsafe("thread-1"),
701 turnCount: 1,
702 createdAt,
703 }),
704 );
705 await Effect.runPromise(
706 harness.engine.dispatch({
707 type: "thread.checkpoint.revert",
708 commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"),
709 threadId: ThreadId.makeUnsafe("thread-1"),
710 turnCount: 0,
711 createdAt,
712 }),
713 );
714
715 const deadline = Date.now() + 2000;
716 const waitForRollbackCalls = async (): Promise<void> => {
717 if (harness.provider.rollbackConversation.mock.calls.length >= 2) {
718 return;
719 }
720 if (Date.now() >= deadline) {
721 throw new Error("Timed out waiting for rollbackConversation calls.");
722 }
723 await new Promise((resolve) => setTimeout(resolve, 10));
724 return waitForRollbackCalls();
725 };
726 await waitForRollbackCalls();
727
728 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
729 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
730 sessionId: asSessionId("sess-1"),
731 numTurns: 1,
732 });
733 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({
734 sessionId: asSessionId("sess-1"),
735 numTurns: 1,
736 });
737 });
738
578 it("appends an error activity when revert is requested without an active session", async () => {739 it("appends an error activity when revert is requested without an active session", async () => {
579 const harness = await createHarness({ hasSession: false });740 const harness = await createHarness({ hasSession: false });
580 const createdAt = new Date().toISOString();741 const createdAt = new Date().toISOString();
apps/server/src/orchestration/Layers/CheckpointReactor.ts+18-3
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9 type OrchestrationEvent,9 type OrchestrationEvent,
10 type ProviderRuntimeEvent,10 type ProviderRuntimeEvent,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { Effect, Layer, Option, Queue, Stream } from "effect";12import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
1313
14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";15import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";
...@@ -537,7 +537,6 @@ const make = Effect.gen(function* () {...@@ -537,7 +537,6 @@ const make = Effect.gen(function* () {
537 createdAt: now,537 createdAt: now,
538 }),538 }),
539 ),539 ),
540 Effect.forkScoped,
541 Effect.asVoid,540 Effect.asVoid,
542 );541 );
543 });542 });
...@@ -672,11 +671,27 @@ const make = Effect.gen(function* () {...@@ -672,11 +671,27 @@ const make = Effect.gen(function* () {
672 ): Effect.Effect<void, CheckpointStoreError | OrchestrationDispatchError, never> =>671 ): Effect.Effect<void, CheckpointStoreError | OrchestrationDispatchError, never> =>
673 input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event);672 input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event);
674673
674 const processInputSafely = (input: ReactorInput) =>
675 processInput(input).pipe(
676 Effect.catchCause((cause) => {
677 if (Cause.hasInterruptsOnly(cause)) {
678 return Effect.failCause(cause);
679 }
680 return Effect.logWarning("checkpoint reactor failed to process input", {
681 source: input.source,
682 eventType: input.event.type,
683 cause: Cause.pretty(cause),
684 });
685 }),
686 );
687
675 const start: CheckpointReactorShape["start"] = Effect.gen(function* () {688 const start: CheckpointReactorShape["start"] = Effect.gen(function* () {
676 const queue = yield* Queue.unbounded<ReactorInput>();689 const queue = yield* Queue.unbounded<ReactorInput>();
677 yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));690 yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));
678691
679 yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInput))));692 yield* Effect.forkScoped(
693 Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInputSafely))),
694 );
680695
681 yield* Effect.forkScoped(696 yield* Effect.forkScoped(
682 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {697 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+36
...@@ -327,4 +327,40 @@ describe("ProviderRuntimeIngestion", () => {...@@ -327,4 +327,40 @@ describe("ProviderRuntimeIngestion", () => {
327 expect(thread.session?.status).toBe("ready");327 expect(thread.session?.status).toBe("ready");
328 expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true);328 expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true);
329 });329 });
330
331 it("continues processing runtime events after a single event handler failure", async () => {
332 const harness = await createHarness();
333 const now = new Date().toISOString();
334
335 harness.emit({
336 type: "message.delta",
337 eventId: asEventId("evt-invalid-delta"),
338 provider: "codex",
339 sessionId: asSessionId("sess-1"),
340 createdAt: now,
341 turnId: asProviderTurnId("turn-invalid"),
342 itemId: asItemId("item-invalid"),
343 delta: undefined,
344 } as unknown as ProviderRuntimeEvent);
345
346 harness.emit({
347 type: "runtime.error",
348 eventId: asEventId("evt-runtime-error-after-failure"),
349 provider: "codex",
350 sessionId: asSessionId("sess-1"),
351 createdAt: new Date().toISOString(),
352 turnId: asProviderTurnId("turn-after-failure"),
353 message: "runtime still processed",
354 });
355
356 const thread = await waitForThread(
357 harness.engine,
358 (entry) =>
359 entry.session?.status === "error" &&
360 entry.session?.activeTurnId === "turn-after-failure" &&
361 entry.session?.lastError === "runtime still processed",
362 );
363 expect(thread.session?.status).toBe("error");
364 expect(thread.session?.lastError).toBe("runtime still processed");
365 });
330});366});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+16-2
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7 type ProviderRuntimeEvent,7 type ProviderRuntimeEvent,
8 type ProviderSessionId,8 type ProviderSessionId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { Effect, Layer, Queue, Stream } from "effect";10import { Cause, Effect, Layer, Queue, Stream } from "effect";
1111
12import { ProviderService } from "../../provider/Services/ProviderService.ts";12import { ProviderService } from "../../provider/Services/ProviderService.ts";
13import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";13import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
...@@ -324,13 +324,27 @@ const make = Effect.gen(function* () {...@@ -324,13 +324,27 @@ const make = Effect.gen(function* () {
324 ).pipe(Effect.asVoid);324 ).pipe(Effect.asVoid);
325 });325 });
326326
327 const processEventSafely = (event: ProviderRuntimeEvent) =>
328 processEvent(event).pipe(
329 Effect.catchCause((cause) => {
330 if (Cause.hasInterruptsOnly(cause)) {
331 return Effect.failCause(cause);
332 }
333 return Effect.logWarning("provider runtime ingestion failed to process event", {
334 eventId: event.eventId,
335 eventType: event.type,
336 cause: Cause.pretty(cause),
337 });
338 }),
339 );
340
327 const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () {341 const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () {
328 const providerEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();342 const providerEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
329343
330 yield* Effect.addFinalizer(() => Queue.shutdown(providerEventQueue).pipe(Effect.asVoid));344 yield* Effect.addFinalizer(() => Queue.shutdown(providerEventQueue).pipe(Effect.asVoid));
331345
332 yield* Effect.forkScoped(346 yield* Effect.forkScoped(
333 Effect.forever(Queue.take(providerEventQueue).pipe(Effect.flatMap(processEvent))),347 Effect.forever(Queue.take(providerEventQueue).pipe(Effect.flatMap(processEventSafely))),
334 );348 );
335 yield* Effect.forkScoped(349 yield* Effect.forkScoped(
336 Stream.runForEach(providerService.streamEvents, (event) =>350 Stream.runForEach(providerService.streamEvents, (event) =>
apps/server/src/wsServer.test.ts+59
...@@ -1000,6 +1000,65 @@ describe("WebSocket Server", () => {...@@ -1000,6 +1000,65 @@ describe("WebSocket Server", () => {
1000 expect(response!.error!.message).toContain("Invalid request format");1000 expect(response!.error!.message).toContain("Invalid request format");
1001 });1001 });
10021002
1003 it("catches websocket message handler rejections and keeps the socket usable", async () => {
1004 const unhandledRejections: unknown[] = [];
1005 const onUnhandledRejection = (reason: unknown) => {
1006 unhandledRejections.push(reason);
1007 };
1008 process.on("unhandledRejection", onUnhandledRejection);
1009
1010 const brokenOpenService: OpenShape = {
1011 openBrowser: () => Effect.void,
1012 openInEditor: () =>
1013 Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
1014 };
1015
1016 try {
1017 server = await createTestServer({ cwd: "/test", open: brokenOpenService });
1018 const addr = server.address();
1019 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1020
1021 const ws = await connectWs(port);
1022 connections.push(ws);
1023 await waitForMessage(ws);
1024
1025 ws.send(
1026 JSON.stringify({
1027 id: "req-broken-open",
1028 body: {
1029 _tag: WS_METHODS.shellOpenInEditor,
1030 cwd: "/tmp",
1031 editor: "cursor",
1032 },
1033 }),
1034 );
1035
1036 await new Promise((resolve) => setTimeout(resolve, 50));
1037 expect(unhandledRejections).toHaveLength(0);
1038
1039 const workspace = makeTempDir("t3code-ws-handler-still-usable-");
1040 fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8");
1041 const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1042 cwd: workspace,
1043 query: "file",
1044 limit: 5,
1045 });
1046 expect(response.error).toBeUndefined();
1047 expect(response.result).toEqual(
1048 expect.objectContaining({
1049 entries: expect.arrayContaining([
1050 expect.objectContaining({
1051 path: "file.txt",
1052 kind: "file",
1053 }),
1054 ]),
1055 }),
1056 );
1057 } finally {
1058 process.off("unhandledRejection", onUnhandledRejection);
1059 }
1060 });
1061
1003 it("returns errors for removed projects CRUD methods", async () => {1062 it("returns errors for removed projects CRUD methods", async () => {
1004 server = await createTestServer({ cwd: "/test" });1063 server = await createTestServer({ cwd: "/test" });
1005 const addr = server.address();1064 const addr = server.address();
apps/server/src/wsServer.ts+5-1
...@@ -542,7 +542,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -542,7 +542,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
542 ws.send(JSON.stringify(welcome));542 ws.send(JSON.stringify(welcome));
543543
544 ws.on("message", (raw) => {544 ws.on("message", (raw) => {
545 void Effect.runPromise(handleMessage(ws, raw));545 void Effect.runPromise(
546 handleMessage(ws, raw).pipe(
547 Effect.catch((error) => Effect.logError("Error handling message", error)),
548 ),
549 );
546 });550 });
547551
548 ws.on("close", () => {552 ws.on("close", () => {