@sanqianx/project-knowledge 5.0.19 → 5.0.20-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/.agents/plugins/marketplace.json +3 -3
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +5 -0
  4. package/LICENSE +201 -201
  5. package/NOTICE +5 -5
  6. package/README.md +349 -349
  7. package/_modules/knowledge-engine/README.md +4 -0
  8. package/_modules/knowledge-engine/src/analysis-failures.js +39 -0
  9. package/_modules/knowledge-engine/src/analysis-worker.js +6 -2
  10. package/_modules/knowledge-engine/src/coordinator.js +7 -0
  11. package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
  12. package/_modules/knowledge-engine/src/server.js +1 -1
  13. package/_site/_test/agent-capture-mcp-ui-test.js +67 -67
  14. package/_site/_test/ai-profile-test.js +75 -75
  15. package/_site/_test/ai-vendor-presets-test.js +91 -91
  16. package/_site/_test/automatic-context-test.js +70 -70
  17. package/_site/_test/bin-cli-test.js +230 -230
  18. package/_site/_test/bridge-adapter-test.js +95 -95
  19. package/_site/_test/bridge-consumer-service-test.js +310 -310
  20. package/_site/_test/candidate-bin-content-test.js +35 -35
  21. package/_site/_test/candidate-product-assets-test.js +13 -13
  22. package/_site/_test/candidate-release-contract-test.js +14 -14
  23. package/_site/_test/capture-hook-dedup-test.js +158 -158
  24. package/_site/_test/claude-executable-discovery-test.js +28 -28
  25. package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
  26. package/_site/_test/cli-instance-management-test.js +62 -62
  27. package/_site/_test/client-capture-restart-e2e-test.js +189 -189
  28. package/_site/_test/codex-conversation-projection-test.js +60 -60
  29. package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
  30. package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
  31. package/_site/_test/codex-notify-fanout-test.js +134 -134
  32. package/_site/_test/codex-notify-upgrade-test.js +253 -253
  33. package/_site/_test/commit-boundary-freeze-test.js +147 -147
  34. package/_site/_test/commit-conversation-binding-test.js +4 -4
  35. package/_site/_test/configuration-generation-test.js +22 -22
  36. package/_site/_test/context-pack-test.js +200 -200
  37. package/_site/_test/conversation-api-test.js +10 -10
  38. package/_site/_test/conversation-origin-test.js +15 -15
  39. package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
  40. package/_site/_test/embedding-config-test.js +32 -32
  41. package/_site/_test/fixtures/fake-browser-exit.js +10 -10
  42. package/_site/_test/fixtures/fake-browser-hang.js +10 -10
  43. package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
  44. package/_site/_test/fixtures/make-git-repos.js +92 -92
  45. package/_site/_test/fixtures/retrieval-gold-queries.json +32 -32
  46. package/_site/_test/fresh-hook-reimport-test.js +53 -53
  47. package/_site/_test/fresh-product-data-test.js +56 -56
  48. package/_site/_test/git-path-compatibility-test.js +80 -80
  49. package/_site/_test/git-validation-test.js +81 -81
  50. package/_site/_test/hook-worktree-test.js +42 -42
  51. package/_site/_test/index-builder-test.js +41 -41
  52. package/_site/_test/index-error-display-contract-test.js +10 -10
  53. package/_site/_test/integration-adapters-test.js +67 -67
  54. package/_site/_test/integration-capture-setup-test.js +118 -118
  55. package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
  56. package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
  57. package/_site/_test/integration-runtime-binding-test.js +473 -473
  58. package/_site/_test/integration-status-scan-test.js +29 -29
  59. package/_site/_test/kb-framework-test.js +57 -57
  60. package/_site/_test/kbpath-follow-test.js +63 -63
  61. package/_site/_test/knowledge-db-test.js +144 -144
  62. package/_site/_test/knowledge-engine-client-test.js +109 -109
  63. package/_site/_test/knowledge-maintenance-test.js +40 -40
  64. package/_site/_test/knowledge-panel-ui-contract-test.js +112 -0
  65. package/_site/_test/knowledge-scopes-test.js +37 -37
  66. package/_site/_test/knowledge-storage-location-test.js +28 -28
  67. package/_site/_test/launcher-readiness-test.js +80 -80
  68. package/_site/_test/legacy-claude-session-alias-test.js +106 -106
  69. package/_site/_test/legacy-routes-removed-test.js +47 -47
  70. package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
  71. package/_site/_test/live-gateway-test.js +48 -48
  72. package/_site/_test/local-marketplace-test.js +16 -16
  73. package/_site/_test/log-redaction-test.js +58 -58
  74. package/_site/_test/markdown-maintenance-test.js +11 -11
  75. package/_site/_test/model-context-windows-test.js +89 -89
  76. package/_site/_test/module-agent-start-proxy-test.js +65 -65
  77. package/_site/_test/module-artifact-integrity-test.js +27 -27
  78. package/_site/_test/module-boundary-test.js +30 -30
  79. package/_site/_test/module-bridge-eventbridge-test.js +202 -202
  80. package/_site/_test/module-dynamic-port-test.js +118 -118
  81. package/_site/_test/module-index-proxy-test.js +33 -33
  82. package/_site/_test/module-model-configuration-test.js +44 -44
  83. package/_site/_test/module-process-lifecycle-test.js +35 -35
  84. package/_site/_test/module-stream-proxy-test.js +34 -34
  85. package/_site/_test/module-terminal-restart-test.js +63 -63
  86. package/_site/_test/module-wiring-unbind-race-test.js +59 -59
  87. package/_site/_test/native-context-integrations-test.js +86 -86
  88. package/_site/_test/orphaned-claim-recovery-test.js +80 -80
  89. package/_site/_test/package-boundary-test.js +43 -42
  90. package/_site/_test/package-startup-test.js +56 -56
  91. package/_site/_test/packaged-ui-smoke-test.js +88 -88
  92. package/_site/_test/pr-consumer-contract-test.js +55 -55
  93. package/_site/_test/product-brand-contract-test.js +25 -25
  94. package/_site/_test/product-diagnostics-ui-test.js +93 -93
  95. package/_site/_test/product-import-ui-test.js +104 -104
  96. package/_site/_test/product-progress-status-test.js +12 -1
  97. package/_site/_test/project-remove-running-guard-test.js +79 -79
  98. package/_site/_test/project-store-test.js +90 -90
  99. package/_site/_test/registry-events-test.js +38 -38
  100. package/_site/_test/release-version-sync-test.js +115 -115
  101. package/_site/_test/requirement-binding-test.js +113 -113
  102. package/_site/_test/runtime-endpoint-test.js +64 -64
  103. package/_site/_test/scanner-test.js +83 -83
  104. package/_site/_test/server-security-test.js +96 -96
  105. package/_site/_test/sessions-stream-test.js +93 -93
  106. package/_site/_test/shared-scope-root-binding-test.js +67 -67
  107. package/_site/_test/simple-import-test.js +98 -98
  108. package/_site/_test/startup-analysis-disabled-test.js +6 -6
  109. package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
  110. package/_site/_test/team-knowledge-store-test.js +132 -132
  111. package/_site/_test/test-server-cleanup-test.js +35 -35
  112. package/_site/_test/test-server-module-isolation-test.js +37 -37
  113. package/_site/_test/tracking-start-test.js +35 -35
  114. package/_site/_test/workbench-context-receipts-test.js +22 -22
  115. package/_site/_test/workspace-ui-contract-test.js +31 -31
  116. package/_site/_test/zcode-capture-integration-test.js +108 -108
  117. package/_site/lib/ai-vendor-presets.js +83 -83
  118. package/_site/lib/ai-workspace.js +3 -3
  119. package/_site/lib/atomic-file.js +204 -204
  120. package/_site/lib/bridge-adapter.js +260 -260
  121. package/_site/lib/capture-hook-config.js +108 -108
  122. package/_site/lib/claude-cli-runner.js +89 -89
  123. package/_site/lib/cli-instances.js +43 -43
  124. package/_site/lib/codex-context-status.js +17 -17
  125. package/_site/lib/codex-notify-config.js +193 -193
  126. package/_site/lib/commit-conversation-binder.js +12 -12
  127. package/_site/lib/commit-reconciler.js +38 -38
  128. package/_site/lib/context-pack-builder.js +299 -299
  129. package/_site/lib/conversation-exclusions.js +204 -204
  130. package/_site/lib/conversation-query-service.js +17 -17
  131. package/_site/lib/data-dir.js +13 -13
  132. package/_site/lib/embedding-service.js +85 -85
  133. package/_site/lib/engine-connection.js +93 -93
  134. package/_site/lib/engine-knowledge-client.js +72 -72
  135. package/_site/lib/folder-picker-output.js +3 -3
  136. package/_site/lib/git-path.js +14 -14
  137. package/_site/lib/github-team-store.js +3 -3
  138. package/_site/lib/index-builder.js +157 -157
  139. package/_site/lib/integration-status-scan.js +99 -99
  140. package/_site/lib/integration-status-worker.js +14 -14
  141. package/_site/lib/kb-framework.js +15 -15
  142. package/_site/lib/knowledge-db.js +363 -363
  143. package/_site/lib/knowledge-schema.js +121 -121
  144. package/_site/lib/live-gateway.js +78 -78
  145. package/_site/lib/llm-client.js +12 -12
  146. package/_site/lib/model-context-windows.js +82 -82
  147. package/_site/lib/module-bridge.js +874 -874
  148. package/_site/lib/module-port-resolver.js +57 -57
  149. package/_site/lib/post-commit-automation.js +54 -54
  150. package/_site/lib/product-data.js +35 -35
  151. package/_site/lib/project-lifecycle-service.js +14 -14
  152. package/_site/lib/project-registry-store.js +129 -129
  153. package/_site/lib/prompt-registry.js +148 -148
  154. package/_site/lib/registry-events.js +16 -16
  155. package/_site/lib/requirement-adapters.js +10 -10
  156. package/_site/lib/requirement-binder.js +112 -112
  157. package/_site/lib/runtime-control.js +89 -89
  158. package/_site/lib/runtime-endpoint.js +5 -5
  159. package/_site/lib/zcode-context-integration.js +60 -60
  160. package/_site/scripts/codex-notify-fanout.cjs +95 -95
  161. package/_site/scripts/install-module-candidates.js +69 -69
  162. package/_site/scripts/pack-module-candidates.js +89 -89
  163. package/_site/scripts/sync-product-content.js +28 -28
  164. package/_site/scripts/sync-release-version.js +164 -164
  165. package/_site/scripts/vendor-modules.js +49 -49
  166. package/_site/scripts/verify-module-runtime.js +50 -50
  167. package/_site/scripts/verify-product-runtime.js +36 -36
  168. package/_site/scripts/verify-retrieval-gold.js +83 -83
  169. package/_site/server.js +15 -15
  170. package/_site/start.bat +26 -26
  171. package/_site/stop.bat +11 -11
  172. package/bin/project-knowledge-integrations.js +254 -254
  173. package/bin/project-knowledge-kb.js +67 -67
  174. package/bin/project-knowledge-mcp.js +8 -8
  175. package/bin/project-knowledge.js +170 -170
  176. package/claude-prompts.json +21 -21
  177. package/docs/README.zh-CN.md +205 -205
  178. package/docs/assets/architecture.svg +181 -181
  179. package/docs/assets/logo.svg +49 -49
  180. package/docs/fresh-v5-start.md +42 -42
  181. package/docs/leader-glm-workbench-playbook.md +122 -122
  182. package/docs/project-registry-schema.md +11 -11
  183. package/docs/release-5.0.16-acceptance.md +176 -176
  184. package/docs/release-5.0.17-acceptance.md +117 -117
  185. package/docs/release-5.0.19-acceptance.md +39 -25
  186. package/docs/team-knowledge-mode-a-plan.md +73 -73
  187. package/module-runtime-manifest.json +53 -33
  188. package/package.json +3 -2
  189. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  190. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  191. package/plugins/project-knowledge/.mcp.json +1 -1
  192. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  193. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  194. package/templates/change.md +30 -30
  195. package/templates/project-readme.md +18 -18
  196. package/ui/index.html +1 -1
  197. package/ui/knowledge-panel.html +118 -0
  198. package/ui/product.css +8 -0
  199. package/ui/product.js +146 -4
  200. package/vendor-manifest.json +55 -54
@@ -1,252 +1,252 @@
1
- // Run: node _site/_test/claude-session-lifecycle-sweep-test.js
2
- //
3
- // Regression test for the stale-active-session sweep in claude-cli-runner.
4
- //
5
- // Without the sweeper, three failure modes leave sessions stuck in
6
- // state=running forever after the subprocess is gone:
7
- // 1. dashboard restart kills the SDK parent query; claude.exe becomes
8
- // orphaned; on reload the persisted record still says "running"
9
- // 2. SIGKILL of the dashboard before the SDK for-await loop emits its
10
- // terminal message
11
- // 3. child Claude.exe dies under IPC drop; in-memory session has
12
- // subprocess=null while state=running
13
- //
14
- // Symptom in production: a project keeps showing the pulsing "running"
15
- // badge and the embedded terminal view stays empty because no live
16
- // subprocess streams into it. Until the sweeper ships, the only manual
17
- // workaround is a dashboard restart that the user has to remember.
18
-
19
- const fs = require('fs');
20
- const os = require('os');
21
- const path = require('path');
22
-
23
- const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), `kb-sweep-test-${process.pid}-`));
24
- process.env.KB_DATA_DIR = TMP_DATA;
25
- process.env.KB_STALE_ACTIVE_MS = '60000';
26
- require(path.resolve(__dirname, '..', '..', '_site', 'lib', 'data-dir'))._resetCache();
27
-
28
- // Seed the v2 registry and per-project config/state before loading the runner.
29
- const PROJECT_IDS = ['demo-zombie-persisted', 'demo-zombie-memory', 'demo-fresh', 'demo-broadcast', 'demo-callback'];
30
- const { defaultProjectConfig, defaultProjectState } = require('../lib/project-store');
31
- const now = new Date().toISOString();
32
- fs.mkdirSync(path.join(TMP_DATA, 'projects'), { recursive: true });
33
- for (const projectId of PROJECT_IDS) {
34
- const knowledgePath = path.join(TMP_DATA, 'knowledge', projectId);
35
- const metadataPath = path.join(TMP_DATA, 'projects', projectId);
36
- fs.mkdirSync(knowledgePath, { recursive: true });
37
- fs.mkdirSync(metadataPath, { recursive: true });
38
- fs.writeFileSync(path.join(metadataPath, 'config.json'), JSON.stringify(defaultProjectConfig(projectId, {
39
- displayName: projectId, storageName: projectId, repoPath: TMP_DATA, knowledgePath,
40
- }), null, 2) + '\n', 'utf8');
41
- fs.writeFileSync(path.join(metadataPath, 'state.json'), JSON.stringify(defaultProjectState(), null, 2) + '\n', 'utf8');
42
- }
43
- fs.writeFileSync(path.join(TMP_DATA, 'projects.json'), JSON.stringify({
44
- schema: 'project-registry/v2', schemaVersion: 2, projectOrder: PROJECT_IDS,
45
- projects: Object.fromEntries(PROJECT_IDS.map(projectId => [projectId, { createdAt: now, displayNameSnapshot: projectId }])),
46
- revision: 1, updatedAt: now,
47
- }, null, 2) + '\n', 'utf8');
48
- fs.writeFileSync(path.join(TMP_DATA, 'claude-prompts.json'), '{"schema":"prompts/v1","prompts":{}}', 'utf-8');
49
-
50
- const runner = require('../lib/claude-cli-runner');
51
- const {
52
- createSession,
53
- demoteStaleActiveSessions,
54
- ACTIVE_STATES,
55
- TERMINAL_STATES,
56
- listSessions,
57
- subscribeList,
58
- } = runner;
59
-
60
- function assert(cond, msg) { if (!cond) throw new Error('ASSERT: ' + msg); }
61
-
62
- function writePersistedRecord(projectSlug, record) {
63
- const aiDir = path.join(TMP_DATA, 'runtime', 'claude-sessions', projectSlug);
64
- fs.mkdirSync(aiDir, { recursive: true });
65
- const target = path.join(aiDir, `${record.sessionId}.json`);
66
- // Default updatedAt to the same stale time used for startedAt so the
67
- // sweeper treats the record as orphan-aged. Callers can override either.
68
- const longAgo = record.startedAt || new Date(Date.now() - 10 * 60 * 1000).toISOString();
69
- fs.writeFileSync(target, JSON.stringify({
70
- schema: 'claude-workbench-session/v1',
71
- updatedAt: record.updatedAt || longAgo,
72
- ...record,
73
- }, null, 2) + '\n', 'utf-8');
74
- return target;
75
- }
76
-
77
- function makeStalePersistedRecord(projectSlug, sessionId, state) {
78
- const longAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
79
- return writePersistedRecord(projectSlug, {
80
- sessionId,
81
- projectSlug,
82
- projectPath: 'D:/proj/' + projectSlug,
83
- kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
84
- promptKey: 'post-commit-automation',
85
- runner: 'sdk',
86
- state,
87
- startedAt: longAgo,
88
- endedAt: null,
89
- exitCode: null,
90
- turns: 1,
91
- error: null,
92
- pendingPermission: null,
93
- source: 'git-hook',
94
- automation: true,
95
- automationRunId: 'auto-test-1',
96
- metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-test-1' },
97
- permissionMode: 'bypassPermissions',
98
- events: [{ type: 'claude/system-prompt', text: 'fake', promptKey: 'post-commit-automation' }],
99
- });
100
- }
101
-
102
- (() => {
103
- // The sweeper itself is the unit under test.
104
- assert(typeof demoteStaleActiveSessions === 'function',
105
- 'demoteStaleActiveSessions must be exported');
106
- assert(ACTIVE_STATES.has('running'),
107
- 'ACTIVE_STATES must include running');
108
- assert(TERMINAL_STATES.has('failed'),
109
- 'TERMINAL_STATES must include failed');
110
-
111
- // (1) Persisted record that's been "running" for 10 minutes with no live
112
- // subprocess — must be demoted. The user's exact scenario: dashboard
113
- // restart orphaned the SDK parent query and the record on disk still
114
- // says running.
115
- {
116
- const sessionId = 'sess-zombie-persisted-' + Date.now();
117
- makeStalePersistedRecord('demo-zombie-persisted', sessionId, 'running');
118
-
119
- const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
120
- const hit = demoted.find(d => d.sessionId === sessionId);
121
- assert(hit, 'sweeper should have demoted the persisted zombie');
122
- assert(hit.source === 'persisted', `expected source=persisted, got ${hit.source}`);
123
-
124
- // The rehydrated session in memory should now show failed + endedAt.
125
- const state = runner.getState(sessionId);
126
- assert(state.state === 'failed',
127
- `expected state=failed after sweep, got ${state.state}`);
128
- assert(state.endedAt, 'endedAt should be set after demotion');
129
- assert(state.error && /subprocess lost/i.test(state.error),
130
- `error message should explain the orphan, got ${state.error}`);
131
-
132
- // Subsequent sweeps should be idempotent — already-failed sessions are
133
- // not in ACTIVE_STATES so they don't get touched again.
134
- const secondSweep = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
135
- const hitAgain = secondSweep.find(d => d.sessionId === sessionId);
136
- assert(!hitAgain,
137
- `sweeper must not re-demote already-failed session (hit again: ${JSON.stringify(secondSweep)})`);
138
- }
139
-
140
- // (2) In-memory session with state=running AND subprocess=null AND a
141
- // stale updatedAt — must be demoted. This is the runtime-orphan case:
142
- // SDK query was kicked off, but the subprocess ref was cleared without
143
- // a terminal setState (e.g. the dashboard process died between the
144
- // subprocess=null assignment and the setState(failed) on the catch path).
145
- {
146
- const projectSlug = 'demo-zombie-memory';
147
- const session = createSession({
148
- projectSlug,
149
- projectPath: 'D:/proj/' + projectSlug,
150
- kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
151
- promptKey: 'post-commit-automation',
152
- source: 'git-hook',
153
- metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-mem' },
154
- });
155
- // Force it into the orphan shape: state=running, subprocess=null,
156
- // updatedAt way in the past.
157
- session.state = 'running';
158
- session.subprocess = null;
159
- session.updatedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
160
- session.startedAt = session.updatedAt;
161
-
162
- const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
163
- const hit = demoted.find(d => d.sessionId === session.sessionId);
164
- assert(hit, 'sweeper should have demoted the in-memory zombie');
165
- assert(hit.source === 'memory', `expected source=memory, got ${hit.source}`);
166
-
167
- const state = runner.getState(session.sessionId);
168
- assert(state.state === 'failed',
169
- `expected in-memory session to be failed, got ${state.state}`);
170
- assert(state.error && /subprocess exited/i.test(state.error),
171
- `expected subprocess-null error, got ${state.error}`);
172
- }
173
-
174
- // (3) Fresh session (started 5s ago) must NOT be demoted even if
175
- // subprocess happens to be null. Threshold exists exactly to keep
176
- // legitimately-thinking sessions from being killed prematurely.
177
- {
178
- const projectSlug = 'demo-fresh';
179
- const session = createSession({
180
- projectSlug,
181
- projectPath: 'D:/proj/' + projectSlug,
182
- kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
183
- promptKey: 'post-commit-automation',
184
- source: 'git-hook',
185
- metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-fresh' },
186
- });
187
- session.state = 'running';
188
- session.subprocess = null;
189
- // updatedAt defaults to createdAt via createSession, well under 60s old.
190
-
191
- const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
192
- const hit = demoted.find(d => d.sessionId === session.sessionId);
193
- assert(!hit,
194
- `fresh session (age <60s) must NOT be demoted; demoted list = ${JSON.stringify(demoted)}`);
195
-
196
- // Cleanup so it doesn't leak into the next test.
197
- runner.deleteSession(session.sessionId);
198
- }
199
-
200
- // (4) Demotion must fire the global SSE broadcast so the UI sees the
201
- // running→failed transition. Without this the blue light stays on even
202
- // after the in-memory state is wrong.
203
- {
204
- const projectSlug = 'demo-broadcast';
205
- const broadcastEvents = [];
206
- subscribeList(ev => broadcastEvents.push(ev));
207
-
208
- const sessionId = 'sess-broadcast-' + Date.now();
209
- makeStalePersistedRecord(projectSlug, sessionId, 'running');
210
-
211
- demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
212
- const sawTransition = broadcastEvents.some(
213
- ev => ev.sessionId === sessionId && ev.state === 'failed' && ev.kind === 'stale-active-sweep'
214
- );
215
- assert(sawTransition,
216
- `expected SSE broadcast for ${sessionId} with state=failed kind=stale-active-sweep; got ${JSON.stringify(broadcastEvents)}`);
217
- }
218
-
219
- // (5) The onSessionEnded hook must fire for failed sessions, so the
220
- // automation queue (post-commit-automation.js) can promote the next
221
- // queued run. Without this, stranded sessions would also strand any
222
- // queued successor forever.
223
- {
224
- const projectSlug = 'demo-callback';
225
- const sessionId = 'sess-callback-' + Date.now();
226
- makeStalePersistedRecord(projectSlug, sessionId, 'running');
227
-
228
- let endedWithFailed = null;
229
- const off = runner.onSessionEnded(s => {
230
- if (s.sessionId === sessionId && s.state === 'failed') {
231
- endedWithFailed = s;
232
- }
233
- });
234
-
235
- demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
236
- // onSessionEnded fires via setImmediate, so wait one tick.
237
- return Promise.resolve()
238
- .then(() => new Promise(resolve => setImmediate(resolve)))
239
- .then(() => {
240
- off();
241
- assert(endedWithFailed,
242
- `onSessionEnded should have fired for the demoted session`);
243
- assert(endedWithFailed.sessionId === sessionId,
244
- `wrong sessionId in callback: ${endedWithFailed && endedWithFailed.sessionId}`);
245
- });
246
- }
247
- })().then(() => {
248
- console.log('claude-session-lifecycle-sweep-test PASS');
249
- }).catch(e => {
250
- console.error(e.stack || e.message);
251
- process.exit(1);
252
- });
1
+ // Run: node _site/_test/claude-session-lifecycle-sweep-test.js
2
+ //
3
+ // Regression test for the stale-active-session sweep in claude-cli-runner.
4
+ //
5
+ // Without the sweeper, three failure modes leave sessions stuck in
6
+ // state=running forever after the subprocess is gone:
7
+ // 1. dashboard restart kills the SDK parent query; claude.exe becomes
8
+ // orphaned; on reload the persisted record still says "running"
9
+ // 2. SIGKILL of the dashboard before the SDK for-await loop emits its
10
+ // terminal message
11
+ // 3. child Claude.exe dies under IPC drop; in-memory session has
12
+ // subprocess=null while state=running
13
+ //
14
+ // Symptom in production: a project keeps showing the pulsing "running"
15
+ // badge and the embedded terminal view stays empty because no live
16
+ // subprocess streams into it. Until the sweeper ships, the only manual
17
+ // workaround is a dashboard restart that the user has to remember.
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+ const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), `kb-sweep-test-${process.pid}-`));
24
+ process.env.KB_DATA_DIR = TMP_DATA;
25
+ process.env.KB_STALE_ACTIVE_MS = '60000';
26
+ require(path.resolve(__dirname, '..', '..', '_site', 'lib', 'data-dir'))._resetCache();
27
+
28
+ // Seed the v2 registry and per-project config/state before loading the runner.
29
+ const PROJECT_IDS = ['demo-zombie-persisted', 'demo-zombie-memory', 'demo-fresh', 'demo-broadcast', 'demo-callback'];
30
+ const { defaultProjectConfig, defaultProjectState } = require('../lib/project-store');
31
+ const now = new Date().toISOString();
32
+ fs.mkdirSync(path.join(TMP_DATA, 'projects'), { recursive: true });
33
+ for (const projectId of PROJECT_IDS) {
34
+ const knowledgePath = path.join(TMP_DATA, 'knowledge', projectId);
35
+ const metadataPath = path.join(TMP_DATA, 'projects', projectId);
36
+ fs.mkdirSync(knowledgePath, { recursive: true });
37
+ fs.mkdirSync(metadataPath, { recursive: true });
38
+ fs.writeFileSync(path.join(metadataPath, 'config.json'), JSON.stringify(defaultProjectConfig(projectId, {
39
+ displayName: projectId, storageName: projectId, repoPath: TMP_DATA, knowledgePath,
40
+ }), null, 2) + '\n', 'utf8');
41
+ fs.writeFileSync(path.join(metadataPath, 'state.json'), JSON.stringify(defaultProjectState(), null, 2) + '\n', 'utf8');
42
+ }
43
+ fs.writeFileSync(path.join(TMP_DATA, 'projects.json'), JSON.stringify({
44
+ schema: 'project-registry/v2', schemaVersion: 2, projectOrder: PROJECT_IDS,
45
+ projects: Object.fromEntries(PROJECT_IDS.map(projectId => [projectId, { createdAt: now, displayNameSnapshot: projectId }])),
46
+ revision: 1, updatedAt: now,
47
+ }, null, 2) + '\n', 'utf8');
48
+ fs.writeFileSync(path.join(TMP_DATA, 'claude-prompts.json'), '{"schema":"prompts/v1","prompts":{}}', 'utf-8');
49
+
50
+ const runner = require('../lib/claude-cli-runner');
51
+ const {
52
+ createSession,
53
+ demoteStaleActiveSessions,
54
+ ACTIVE_STATES,
55
+ TERMINAL_STATES,
56
+ listSessions,
57
+ subscribeList,
58
+ } = runner;
59
+
60
+ function assert(cond, msg) { if (!cond) throw new Error('ASSERT: ' + msg); }
61
+
62
+ function writePersistedRecord(projectSlug, record) {
63
+ const aiDir = path.join(TMP_DATA, 'runtime', 'claude-sessions', projectSlug);
64
+ fs.mkdirSync(aiDir, { recursive: true });
65
+ const target = path.join(aiDir, `${record.sessionId}.json`);
66
+ // Default updatedAt to the same stale time used for startedAt so the
67
+ // sweeper treats the record as orphan-aged. Callers can override either.
68
+ const longAgo = record.startedAt || new Date(Date.now() - 10 * 60 * 1000).toISOString();
69
+ fs.writeFileSync(target, JSON.stringify({
70
+ schema: 'claude-workbench-session/v1',
71
+ updatedAt: record.updatedAt || longAgo,
72
+ ...record,
73
+ }, null, 2) + '\n', 'utf-8');
74
+ return target;
75
+ }
76
+
77
+ function makeStalePersistedRecord(projectSlug, sessionId, state) {
78
+ const longAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
79
+ return writePersistedRecord(projectSlug, {
80
+ sessionId,
81
+ projectSlug,
82
+ projectPath: 'D:/proj/' + projectSlug,
83
+ kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
84
+ promptKey: 'post-commit-automation',
85
+ runner: 'sdk',
86
+ state,
87
+ startedAt: longAgo,
88
+ endedAt: null,
89
+ exitCode: null,
90
+ turns: 1,
91
+ error: null,
92
+ pendingPermission: null,
93
+ source: 'git-hook',
94
+ automation: true,
95
+ automationRunId: 'auto-test-1',
96
+ metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-test-1' },
97
+ permissionMode: 'bypassPermissions',
98
+ events: [{ type: 'claude/system-prompt', text: 'fake', promptKey: 'post-commit-automation' }],
99
+ });
100
+ }
101
+
102
+ (() => {
103
+ // The sweeper itself is the unit under test.
104
+ assert(typeof demoteStaleActiveSessions === 'function',
105
+ 'demoteStaleActiveSessions must be exported');
106
+ assert(ACTIVE_STATES.has('running'),
107
+ 'ACTIVE_STATES must include running');
108
+ assert(TERMINAL_STATES.has('failed'),
109
+ 'TERMINAL_STATES must include failed');
110
+
111
+ // (1) Persisted record that's been "running" for 10 minutes with no live
112
+ // subprocess — must be demoted. The user's exact scenario: dashboard
113
+ // restart orphaned the SDK parent query and the record on disk still
114
+ // says running.
115
+ {
116
+ const sessionId = 'sess-zombie-persisted-' + Date.now();
117
+ makeStalePersistedRecord('demo-zombie-persisted', sessionId, 'running');
118
+
119
+ const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
120
+ const hit = demoted.find(d => d.sessionId === sessionId);
121
+ assert(hit, 'sweeper should have demoted the persisted zombie');
122
+ assert(hit.source === 'persisted', `expected source=persisted, got ${hit.source}`);
123
+
124
+ // The rehydrated session in memory should now show failed + endedAt.
125
+ const state = runner.getState(sessionId);
126
+ assert(state.state === 'failed',
127
+ `expected state=failed after sweep, got ${state.state}`);
128
+ assert(state.endedAt, 'endedAt should be set after demotion');
129
+ assert(state.error && /subprocess lost/i.test(state.error),
130
+ `error message should explain the orphan, got ${state.error}`);
131
+
132
+ // Subsequent sweeps should be idempotent — already-failed sessions are
133
+ // not in ACTIVE_STATES so they don't get touched again.
134
+ const secondSweep = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
135
+ const hitAgain = secondSweep.find(d => d.sessionId === sessionId);
136
+ assert(!hitAgain,
137
+ `sweeper must not re-demote already-failed session (hit again: ${JSON.stringify(secondSweep)})`);
138
+ }
139
+
140
+ // (2) In-memory session with state=running AND subprocess=null AND a
141
+ // stale updatedAt — must be demoted. This is the runtime-orphan case:
142
+ // SDK query was kicked off, but the subprocess ref was cleared without
143
+ // a terminal setState (e.g. the dashboard process died between the
144
+ // subprocess=null assignment and the setState(failed) on the catch path).
145
+ {
146
+ const projectSlug = 'demo-zombie-memory';
147
+ const session = createSession({
148
+ projectSlug,
149
+ projectPath: 'D:/proj/' + projectSlug,
150
+ kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
151
+ promptKey: 'post-commit-automation',
152
+ source: 'git-hook',
153
+ metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-mem' },
154
+ });
155
+ // Force it into the orphan shape: state=running, subprocess=null,
156
+ // updatedAt way in the past.
157
+ session.state = 'running';
158
+ session.subprocess = null;
159
+ session.updatedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
160
+ session.startedAt = session.updatedAt;
161
+
162
+ const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
163
+ const hit = demoted.find(d => d.sessionId === session.sessionId);
164
+ assert(hit, 'sweeper should have demoted the in-memory zombie');
165
+ assert(hit.source === 'memory', `expected source=memory, got ${hit.source}`);
166
+
167
+ const state = runner.getState(session.sessionId);
168
+ assert(state.state === 'failed',
169
+ `expected in-memory session to be failed, got ${state.state}`);
170
+ assert(state.error && /subprocess exited/i.test(state.error),
171
+ `expected subprocess-null error, got ${state.error}`);
172
+ }
173
+
174
+ // (3) Fresh session (started 5s ago) must NOT be demoted even if
175
+ // subprocess happens to be null. Threshold exists exactly to keep
176
+ // legitimately-thinking sessions from being killed prematurely.
177
+ {
178
+ const projectSlug = 'demo-fresh';
179
+ const session = createSession({
180
+ projectSlug,
181
+ projectPath: 'D:/proj/' + projectSlug,
182
+ kbPath: path.join(TMP_DATA, 'knowledge', projectSlug),
183
+ promptKey: 'post-commit-automation',
184
+ source: 'git-hook',
185
+ metadata: { source: 'git-hook', automation: true, automationRunId: 'auto-fresh' },
186
+ });
187
+ session.state = 'running';
188
+ session.subprocess = null;
189
+ // updatedAt defaults to createdAt via createSession, well under 60s old.
190
+
191
+ const demoted = demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
192
+ const hit = demoted.find(d => d.sessionId === session.sessionId);
193
+ assert(!hit,
194
+ `fresh session (age <60s) must NOT be demoted; demoted list = ${JSON.stringify(demoted)}`);
195
+
196
+ // Cleanup so it doesn't leak into the next test.
197
+ runner.deleteSession(session.sessionId);
198
+ }
199
+
200
+ // (4) Demotion must fire the global SSE broadcast so the UI sees the
201
+ // running→failed transition. Without this the blue light stays on even
202
+ // after the in-memory state is wrong.
203
+ {
204
+ const projectSlug = 'demo-broadcast';
205
+ const broadcastEvents = [];
206
+ subscribeList(ev => broadcastEvents.push(ev));
207
+
208
+ const sessionId = 'sess-broadcast-' + Date.now();
209
+ makeStalePersistedRecord(projectSlug, sessionId, 'running');
210
+
211
+ demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
212
+ const sawTransition = broadcastEvents.some(
213
+ ev => ev.sessionId === sessionId && ev.state === 'failed' && ev.kind === 'stale-active-sweep'
214
+ );
215
+ assert(sawTransition,
216
+ `expected SSE broadcast for ${sessionId} with state=failed kind=stale-active-sweep; got ${JSON.stringify(broadcastEvents)}`);
217
+ }
218
+
219
+ // (5) The onSessionEnded hook must fire for failed sessions, so the
220
+ // automation queue (post-commit-automation.js) can promote the next
221
+ // queued run. Without this, stranded sessions would also strand any
222
+ // queued successor forever.
223
+ {
224
+ const projectSlug = 'demo-callback';
225
+ const sessionId = 'sess-callback-' + Date.now();
226
+ makeStalePersistedRecord(projectSlug, sessionId, 'running');
227
+
228
+ let endedWithFailed = null;
229
+ const off = runner.onSessionEnded(s => {
230
+ if (s.sessionId === sessionId && s.state === 'failed') {
231
+ endedWithFailed = s;
232
+ }
233
+ });
234
+
235
+ demoteStaleActiveSessions({ thresholdMs: 60 * 1000 });
236
+ // onSessionEnded fires via setImmediate, so wait one tick.
237
+ return Promise.resolve()
238
+ .then(() => new Promise(resolve => setImmediate(resolve)))
239
+ .then(() => {
240
+ off();
241
+ assert(endedWithFailed,
242
+ `onSessionEnded should have fired for the demoted session`);
243
+ assert(endedWithFailed.sessionId === sessionId,
244
+ `wrong sessionId in callback: ${endedWithFailed && endedWithFailed.sessionId}`);
245
+ });
246
+ }
247
+ })().then(() => {
248
+ console.log('claude-session-lifecycle-sweep-test PASS');
249
+ }).catch(e => {
250
+ console.error(e.stack || e.message);
251
+ process.exit(1);
252
+ });
@@ -1,62 +1,62 @@
1
- 'use strict';
2
- const assert = require('node:assert/strict');
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
- const os = require('node:os');
6
- const http = require('node:http');
7
- const { spawn } = require('node:child_process');
8
- const endpoint = require('../lib/runtime-endpoint');
9
- const control = require('../lib/runtime-control');
10
- const packageRoot = process.env.KB_CLI_TEST_PACKAGE || path.resolve(__dirname, '../..');
11
- const bin = path.join(packageRoot, 'bin/project-knowledge.js');
12
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-instance-management-'));
13
- const a = path.join(root, '知识库 A'), b = path.join(root, '知识库 B');
14
- const temp = path.join(root, 'isolated-temp'); fs.mkdirSync(temp);
15
- const env = { ...process.env, KB_DATA_DIR: a, TEMP: temp, TMP: temp, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
16
- const pids = [];
17
- function run(args) {
18
- return new Promise((resolve, reject) => {
19
- const child = spawn(process.execPath, [bin, ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
20
- let stdout = '', stderr = '';
21
- const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timeout: ${stdout} ${stderr}`)); }, 25000);
22
- child.stdout.on('data', c => { stdout += c; }); child.stderr.on('data', c => { stderr += c; });
23
- child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
24
- });
25
- }
26
- async function usable(port) { try { return (await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(700) })).ok; } catch { return false; } }
27
- let foreign;
28
- (async () => {
29
- foreign = http.createServer((req, res) => { res.writeHead(404); res.end('not knowledge'); });
30
- await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
31
- const foreignPort = foreign.address().port;
32
- fs.writeFileSync(path.join(temp, '.project-knowledge.pid'), `${process.pid}\n${foreignPort}`);
33
- for (const args of [['stoop'], ['STOP', 'unexpected'], ['stop', '--port', '12junk'], ['stop', '--port', '65536'], ['stop', '--port'], ['stop', '--all']]) {
34
- const result = await run(args); assert.equal(result.code, 1, JSON.stringify(result));
35
- assert(!/Already running|starting in background/.test(result.stdout));
36
- }
37
- assert.equal(fs.existsSync(a), false, 'invalid commands must not create data or services');
38
- const noInstance = await run(['STOP']); assert.equal(noInstance.code, 0); assert.match(noInstance.stdout, /No background/);
39
- assert.equal(await usable(foreignPort), false); assert(foreign.listening, 'shared legacy PID must not kill an unrelated process');
40
- const launchedA = await run(['START', '--no-open']); assert.equal(launchedA.code, 0, launchedA.stderr);
41
- const infoA = endpoint.readEndpoint(a); pids.push(infoA.pid); assert(await usable(infoA.port));
42
- const launchedB = await run(['start', '--data', b, '--no-open']); assert.equal(launchedB.code, 0, launchedB.stderr);
43
- const infoB = endpoint.readEndpoint(b); pids.push(infoB.pid); assert(await usable(infoB.port)); assert.notEqual(infoA.port, infoB.port);
44
- const status = await run(['STATUS']); assert.equal(status.code, 0); assert.match(status.stdout, new RegExp(`PID ${infoA.pid}`));
45
- const inventory = await run(['STATUS', '--all']); assert.equal(inventory.code, 0, inventory.stderr);
46
- assert.match(inventory.stdout, new RegExp(`PID ${infoA.pid}`)); assert.match(inventory.stdout, new RegExp(`PID ${infoB.pid}`));
47
- const wrongPort = await run(['STOP', '--port', String(foreignPort)]); assert.equal(wrongPort.code, 1); assert(foreign.listening);
48
- const conflict = await run(['stop', '--data', a, '--port', String(infoB.port)]); assert.equal(conflict.code, 1); assert(await usable(infoB.port));
49
- await assert.rejects(() => control.request(a, { action: 'stop', pid: infoA.pid + 1, port: infoA.port, startedAt: infoA.startedAt }), /身份/);
50
- assert(await usable(infoA.port));
51
- const stoppedA = await run(['STOP']); assert.equal(stoppedA.code, 0, stoppedA.stderr); assert.match(stoppedA.stdout, new RegExp(`:${infoA.port}`));
52
- assert.equal(await usable(infoA.port), false); assert(await usable(infoB.port), 'default stop must preserve a different data directory');
53
- const stoppedB = await run(['stop', '--port', String(infoB.port)]); assert.equal(stoppedB.code, 0, stoppedB.stderr); assert.equal(await usable(infoB.port), false);
54
- assert.equal((await run(['stop'])).code, 0, 'repeated stop is harmless');
55
- fs.writeFileSync(path.join(a, 'runtime-endpoint.json'), JSON.stringify({ schema: endpoint.SCHEMA, pid: process.pid, host: '127.0.0.1', port: foreignPort }));
56
- const stale = await run(['STOP']); assert.equal(stale.code, 1); assert.match(stale.stderr, /控制通道不可用/); assert(foreign.listening, 'a live but unrelated recorded PID is never signaled');
57
- const refused = await run(['start', '--no-open']); assert.equal(refused.code, 1); assert(foreign.listening);
58
- console.log('CLI instance management PASS: case, strict arguments, inventory, two directories, targeted stop, spoof protection, stale/shared PID safety');
59
- })().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
60
- foreign?.closeAllConnections(); if (foreign?.listening) await new Promise(resolve => foreign.close(resolve));
61
- for (const data of [a, b]) try { const info = await control.request(data, { action: 'status' }, 500); await control.request(data, { action: 'stop', pid: info.instance.pid, port: info.instance.port, startedAt: info.instance.startedAt }); } catch {}
62
- });
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const http = require('node:http');
7
+ const { spawn } = require('node:child_process');
8
+ const endpoint = require('../lib/runtime-endpoint');
9
+ const control = require('../lib/runtime-control');
10
+ const packageRoot = process.env.KB_CLI_TEST_PACKAGE || path.resolve(__dirname, '../..');
11
+ const bin = path.join(packageRoot, 'bin/project-knowledge.js');
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-instance-management-'));
13
+ const a = path.join(root, '知识库 A'), b = path.join(root, '知识库 B');
14
+ const temp = path.join(root, 'isolated-temp'); fs.mkdirSync(temp);
15
+ const env = { ...process.env, KB_DATA_DIR: a, TEMP: temp, TMP: temp, KB_MODULES_AUTOSTART: '0', AI_CODING_EVENT_BRIDGE_HOME: path.join(root, 'bridge') };
16
+ const pids = [];
17
+ function run(args) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(process.execPath, [bin, ...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
20
+ let stdout = '', stderr = '';
21
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`CLI timeout: ${stdout} ${stderr}`)); }, 25000);
22
+ child.stdout.on('data', c => { stdout += c; }); child.stderr.on('data', c => { stderr += c; });
23
+ child.once('error', reject); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
24
+ });
25
+ }
26
+ async function usable(port) { try { return (await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(700) })).ok; } catch { return false; } }
27
+ let foreign;
28
+ (async () => {
29
+ foreign = http.createServer((req, res) => { res.writeHead(404); res.end('not knowledge'); });
30
+ await new Promise(resolve => foreign.listen(0, '127.0.0.1', resolve));
31
+ const foreignPort = foreign.address().port;
32
+ fs.writeFileSync(path.join(temp, '.project-knowledge.pid'), `${process.pid}\n${foreignPort}`);
33
+ for (const args of [['stoop'], ['STOP', 'unexpected'], ['stop', '--port', '12junk'], ['stop', '--port', '65536'], ['stop', '--port'], ['stop', '--all']]) {
34
+ const result = await run(args); assert.equal(result.code, 1, JSON.stringify(result));
35
+ assert(!/Already running|starting in background/.test(result.stdout));
36
+ }
37
+ assert.equal(fs.existsSync(a), false, 'invalid commands must not create data or services');
38
+ const noInstance = await run(['STOP']); assert.equal(noInstance.code, 0); assert.match(noInstance.stdout, /No background/);
39
+ assert.equal(await usable(foreignPort), false); assert(foreign.listening, 'shared legacy PID must not kill an unrelated process');
40
+ const launchedA = await run(['START', '--no-open']); assert.equal(launchedA.code, 0, launchedA.stderr);
41
+ const infoA = endpoint.readEndpoint(a); pids.push(infoA.pid); assert(await usable(infoA.port));
42
+ const launchedB = await run(['start', '--data', b, '--no-open']); assert.equal(launchedB.code, 0, launchedB.stderr);
43
+ const infoB = endpoint.readEndpoint(b); pids.push(infoB.pid); assert(await usable(infoB.port)); assert.notEqual(infoA.port, infoB.port);
44
+ const status = await run(['STATUS']); assert.equal(status.code, 0); assert.match(status.stdout, new RegExp(`PID ${infoA.pid}`));
45
+ const inventory = await run(['STATUS', '--all']); assert.equal(inventory.code, 0, inventory.stderr);
46
+ assert.match(inventory.stdout, new RegExp(`PID ${infoA.pid}`)); assert.match(inventory.stdout, new RegExp(`PID ${infoB.pid}`));
47
+ const wrongPort = await run(['STOP', '--port', String(foreignPort)]); assert.equal(wrongPort.code, 1); assert(foreign.listening);
48
+ const conflict = await run(['stop', '--data', a, '--port', String(infoB.port)]); assert.equal(conflict.code, 1); assert(await usable(infoB.port));
49
+ await assert.rejects(() => control.request(a, { action: 'stop', pid: infoA.pid + 1, port: infoA.port, startedAt: infoA.startedAt }), /身份/);
50
+ assert(await usable(infoA.port));
51
+ const stoppedA = await run(['STOP']); assert.equal(stoppedA.code, 0, stoppedA.stderr); assert.match(stoppedA.stdout, new RegExp(`:${infoA.port}`));
52
+ assert.equal(await usable(infoA.port), false); assert(await usable(infoB.port), 'default stop must preserve a different data directory');
53
+ const stoppedB = await run(['stop', '--port', String(infoB.port)]); assert.equal(stoppedB.code, 0, stoppedB.stderr); assert.equal(await usable(infoB.port), false);
54
+ assert.equal((await run(['stop'])).code, 0, 'repeated stop is harmless');
55
+ fs.writeFileSync(path.join(a, 'runtime-endpoint.json'), JSON.stringify({ schema: endpoint.SCHEMA, pid: process.pid, host: '127.0.0.1', port: foreignPort }));
56
+ const stale = await run(['STOP']); assert.equal(stale.code, 1); assert.match(stale.stderr, /控制通道不可用/); assert(foreign.listening, 'a live but unrelated recorded PID is never signaled');
57
+ const refused = await run(['start', '--no-open']); assert.equal(refused.code, 1); assert(foreign.listening);
58
+ console.log('CLI instance management PASS: case, strict arguments, inventory, two directories, targeted stop, spoof protection, stale/shared PID safety');
59
+ })().catch(error => { console.error(error); process.exitCode = 1; }).finally(async () => {
60
+ foreign?.closeAllConnections(); if (foreign?.listening) await new Promise(resolve => foreign.close(resolve));
61
+ for (const data of [a, b]) try { const info = await control.request(data, { action: 'status' }, 500); await control.request(data, { action: 'stop', pid: info.instance.pid, port: info.instance.port, startedAt: info.instance.startedAt }); } catch {}
62
+ });