@sanqianx/project-knowledge 5.0.18 → 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 (209) hide show
  1. package/.agents/plugins/marketplace.json +3 -3
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +14 -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-contract.js +4 -3
  9. package/_modules/knowledge-engine/src/analysis-failures.js +39 -0
  10. package/_modules/knowledge-engine/src/analysis-worker.js +6 -2
  11. package/_modules/knowledge-engine/src/coordinator.js +8 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
  13. package/_modules/knowledge-engine/src/memories.js +10 -5
  14. package/_modules/knowledge-engine/src/memory-eligibility.js +74 -0
  15. package/_modules/knowledge-engine/src/retrieval-ranking.js +19 -1
  16. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +1 -0
  17. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +12 -9
  18. package/_modules/knowledge-engine/src/runtime/conversation-store.js +2 -0
  19. package/_modules/knowledge-engine/src/server.js +1 -1
  20. package/_site/_test/agent-capture-mcp-ui-test.js +67 -67
  21. package/_site/_test/ai-profile-test.js +75 -75
  22. package/_site/_test/ai-vendor-presets-test.js +91 -91
  23. package/_site/_test/automatic-context-test.js +70 -70
  24. package/_site/_test/bin-cli-test.js +230 -230
  25. package/_site/_test/bridge-adapter-test.js +95 -95
  26. package/_site/_test/bridge-consumer-service-test.js +310 -310
  27. package/_site/_test/candidate-bin-content-test.js +35 -0
  28. package/_site/_test/candidate-product-assets-test.js +13 -0
  29. package/_site/_test/candidate-release-contract-test.js +14 -14
  30. package/_site/_test/capture-hook-dedup-test.js +158 -158
  31. package/_site/_test/claude-executable-discovery-test.js +28 -28
  32. package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
  33. package/_site/_test/cli-instance-management-test.js +62 -62
  34. package/_site/_test/client-capture-restart-e2e-test.js +189 -189
  35. package/_site/_test/codex-conversation-projection-test.js +60 -60
  36. package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
  37. package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
  38. package/_site/_test/codex-notify-fanout-test.js +134 -134
  39. package/_site/_test/codex-notify-upgrade-test.js +253 -253
  40. package/_site/_test/commit-boundary-freeze-test.js +147 -147
  41. package/_site/_test/commit-conversation-binding-test.js +4 -4
  42. package/_site/_test/configuration-generation-test.js +22 -22
  43. package/_site/_test/context-pack-test.js +200 -200
  44. package/_site/_test/conversation-api-test.js +10 -10
  45. package/_site/_test/conversation-origin-test.js +15 -0
  46. package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
  47. package/_site/_test/embedding-config-test.js +32 -32
  48. package/_site/_test/fixtures/fake-browser-exit.js +10 -10
  49. package/_site/_test/fixtures/fake-browser-hang.js +10 -10
  50. package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
  51. package/_site/_test/fixtures/make-git-repos.js +92 -92
  52. package/_site/_test/fixtures/retrieval-gold-queries.json +32 -0
  53. package/_site/_test/fresh-hook-reimport-test.js +53 -53
  54. package/_site/_test/fresh-product-data-test.js +56 -56
  55. package/_site/_test/git-path-compatibility-test.js +80 -80
  56. package/_site/_test/git-validation-test.js +81 -81
  57. package/_site/_test/hook-worktree-test.js +42 -42
  58. package/_site/_test/index-builder-test.js +41 -41
  59. package/_site/_test/index-error-display-contract-test.js +10 -10
  60. package/_site/_test/integration-adapters-test.js +67 -67
  61. package/_site/_test/integration-capture-setup-test.js +118 -118
  62. package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
  63. package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
  64. package/_site/_test/integration-runtime-binding-test.js +473 -473
  65. package/_site/_test/integration-status-scan-test.js +29 -29
  66. package/_site/_test/kb-framework-test.js +57 -57
  67. package/_site/_test/kbpath-follow-test.js +63 -63
  68. package/_site/_test/knowledge-db-test.js +144 -144
  69. package/_site/_test/knowledge-engine-client-test.js +109 -109
  70. package/_site/_test/knowledge-maintenance-test.js +40 -40
  71. package/_site/_test/knowledge-panel-ui-contract-test.js +112 -0
  72. package/_site/_test/knowledge-scopes-test.js +37 -37
  73. package/_site/_test/knowledge-storage-location-test.js +28 -28
  74. package/_site/_test/launcher-readiness-test.js +80 -80
  75. package/_site/_test/legacy-claude-session-alias-test.js +106 -106
  76. package/_site/_test/legacy-routes-removed-test.js +47 -47
  77. package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
  78. package/_site/_test/live-gateway-test.js +48 -48
  79. package/_site/_test/local-marketplace-test.js +16 -16
  80. package/_site/_test/log-redaction-test.js +58 -58
  81. package/_site/_test/markdown-maintenance-test.js +11 -11
  82. package/_site/_test/model-context-windows-test.js +89 -89
  83. package/_site/_test/module-agent-start-proxy-test.js +65 -65
  84. package/_site/_test/module-artifact-integrity-test.js +27 -27
  85. package/_site/_test/module-boundary-test.js +30 -30
  86. package/_site/_test/module-bridge-eventbridge-test.js +202 -202
  87. package/_site/_test/module-dynamic-port-test.js +118 -118
  88. package/_site/_test/module-index-proxy-test.js +33 -0
  89. package/_site/_test/module-model-configuration-test.js +44 -44
  90. package/_site/_test/module-process-lifecycle-test.js +35 -35
  91. package/_site/_test/module-stream-proxy-test.js +34 -34
  92. package/_site/_test/module-terminal-restart-test.js +63 -0
  93. package/_site/_test/module-wiring-unbind-race-test.js +59 -59
  94. package/_site/_test/native-context-integrations-test.js +86 -86
  95. package/_site/_test/orphaned-claim-recovery-test.js +80 -80
  96. package/_site/_test/package-boundary-test.js +43 -42
  97. package/_site/_test/package-startup-test.js +56 -56
  98. package/_site/_test/packaged-ui-smoke-test.js +88 -88
  99. package/_site/_test/pr-consumer-contract-test.js +55 -55
  100. package/_site/_test/product-brand-contract-test.js +25 -25
  101. package/_site/_test/product-diagnostics-ui-test.js +93 -93
  102. package/_site/_test/product-import-ui-test.js +104 -104
  103. package/_site/_test/product-progress-status-test.js +12 -1
  104. package/_site/_test/project-remove-running-guard-test.js +79 -79
  105. package/_site/_test/project-store-test.js +90 -90
  106. package/_site/_test/registry-events-test.js +38 -0
  107. package/_site/_test/release-version-sync-test.js +115 -115
  108. package/_site/_test/requirement-binding-test.js +113 -113
  109. package/_site/_test/runtime-endpoint-test.js +64 -64
  110. package/_site/_test/scanner-test.js +83 -83
  111. package/_site/_test/server-security-test.js +96 -96
  112. package/_site/_test/sessions-stream-test.js +93 -93
  113. package/_site/_test/shared-scope-root-binding-test.js +67 -67
  114. package/_site/_test/simple-import-test.js +98 -98
  115. package/_site/_test/startup-analysis-disabled-test.js +6 -6
  116. package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
  117. package/_site/_test/team-knowledge-store-test.js +132 -132
  118. package/_site/_test/test-server-cleanup-test.js +35 -35
  119. package/_site/_test/test-server-module-isolation-test.js +37 -37
  120. package/_site/_test/tracking-start-test.js +35 -35
  121. package/_site/_test/workbench-context-receipts-test.js +22 -0
  122. package/_site/_test/workspace-ui-contract-test.js +31 -31
  123. package/_site/_test/zcode-capture-integration-test.js +108 -108
  124. package/_site/lib/ai-vendor-presets.js +83 -83
  125. package/_site/lib/ai-workspace.js +3 -3
  126. package/_site/lib/atomic-file.js +204 -204
  127. package/_site/lib/bridge-adapter.js +260 -260
  128. package/_site/lib/capture-hook-config.js +108 -108
  129. package/_site/lib/claude-cli-runner.js +89 -89
  130. package/_site/lib/cli-instances.js +43 -43
  131. package/_site/lib/codex-context-status.js +17 -17
  132. package/_site/lib/codex-notify-config.js +193 -193
  133. package/_site/lib/commit-conversation-binder.js +13 -12
  134. package/_site/lib/commit-reconciler.js +38 -38
  135. package/_site/lib/context-pack-builder.js +299 -299
  136. package/_site/lib/conversation-exclusions.js +204 -204
  137. package/_site/lib/conversation-query-service.js +17 -17
  138. package/_site/lib/conversation-store.js +2 -0
  139. package/_site/lib/data-dir.js +13 -13
  140. package/_site/lib/embedding-service.js +85 -85
  141. package/_site/lib/engine-connection.js +93 -90
  142. package/_site/lib/engine-knowledge-client.js +72 -72
  143. package/_site/lib/folder-picker-output.js +3 -3
  144. package/_site/lib/git-path.js +14 -14
  145. package/_site/lib/github-team-store.js +3 -3
  146. package/_site/lib/index-builder.js +157 -157
  147. package/_site/lib/integration-status-scan.js +99 -99
  148. package/_site/lib/integration-status-worker.js +14 -14
  149. package/_site/lib/kb-framework.js +15 -15
  150. package/_site/lib/knowledge-db.js +363 -363
  151. package/_site/lib/knowledge-schema.js +121 -121
  152. package/_site/lib/live-gateway.js +78 -76
  153. package/_site/lib/llm-client.js +12 -12
  154. package/_site/lib/model-context-windows.js +82 -82
  155. package/_site/lib/module-bridge.js +874 -865
  156. package/_site/lib/module-port-resolver.js +57 -57
  157. package/_site/lib/post-commit-automation.js +54 -54
  158. package/_site/lib/product-data.js +35 -35
  159. package/_site/lib/project-lifecycle-service.js +14 -14
  160. package/_site/lib/project-registry-store.js +129 -129
  161. package/_site/lib/prompt-registry.js +148 -148
  162. package/_site/lib/registry-events.js +16 -0
  163. package/_site/lib/requirement-adapters.js +10 -10
  164. package/_site/lib/requirement-binder.js +112 -112
  165. package/_site/lib/runtime-control.js +89 -89
  166. package/_site/lib/runtime-endpoint.js +5 -5
  167. package/_site/lib/server-app.js +10 -1
  168. package/_site/lib/zcode-context-integration.js +60 -60
  169. package/_site/scripts/codex-notify-fanout.cjs +95 -95
  170. package/_site/scripts/install-module-candidates.js +69 -69
  171. package/_site/scripts/pack-module-candidates.js +89 -72
  172. package/_site/scripts/sync-product-content.js +28 -28
  173. package/_site/scripts/sync-release-version.js +164 -164
  174. package/_site/scripts/vendor-modules.js +49 -49
  175. package/_site/scripts/verify-module-runtime.js +50 -50
  176. package/_site/scripts/verify-product-runtime.js +36 -36
  177. package/_site/scripts/verify-retrieval-gold.js +83 -0
  178. package/_site/server.js +15 -15
  179. package/_site/start.bat +26 -26
  180. package/_site/stop.bat +11 -11
  181. package/bin/project-knowledge-integrations.js +254 -254
  182. package/bin/project-knowledge-kb.js +67 -67
  183. package/bin/project-knowledge-mcp.js +8 -8
  184. package/bin/project-knowledge.js +170 -170
  185. package/claude-prompts.json +21 -21
  186. package/docs/README.zh-CN.md +205 -205
  187. package/docs/assets/architecture.svg +181 -181
  188. package/docs/assets/logo.svg +49 -49
  189. package/docs/fresh-v5-start.md +42 -42
  190. package/docs/leader-glm-workbench-playbook.md +122 -119
  191. package/docs/project-registry-schema.md +11 -11
  192. package/docs/release-5.0.16-acceptance.md +176 -176
  193. package/docs/release-5.0.17-acceptance.md +117 -117
  194. package/docs/release-5.0.19-acceptance.md +39 -0
  195. package/docs/team-knowledge-mode-a-plan.md +73 -73
  196. package/module-runtime-manifest.json +126 -104
  197. package/package.json +110 -108
  198. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  199. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  200. package/plugins/project-knowledge/.mcp.json +1 -1
  201. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  202. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  203. package/templates/change.md +30 -30
  204. package/templates/project-readme.md +18 -18
  205. package/ui/index.html +1 -1
  206. package/ui/knowledge-panel.html +118 -0
  207. package/ui/product.css +8 -0
  208. package/ui/product.js +175 -8
  209. package/vendor-manifest.json +55 -53
@@ -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
+ });