@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -2,12 +2,13 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { db } from "./db.js";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
+
import { patrolExpiredTasks } from "./server.js";
|
|
5
6
|
|
|
6
7
|
// RFC-027 PR1 — handler-driven tests for stop_node, delete_node,
|
|
7
8
|
// get_stop_request, ack_stop_request. Drives the MCP handlers via
|
|
8
9
|
// registerTools() so the wired auth context is real (not the
|
|
9
10
|
// mirror-SQL pattern that PR2 v1 used and which let SEC-1 leak slip
|
|
10
|
-
// through
|
|
11
|
+
// through — per team rule: independent fork-review catches wired-wrong even when unit tests pass).
|
|
11
12
|
//
|
|
12
13
|
// Covers RFC-027 P1 e2e plan §5.2 scenarios A/B/C/D/E/F at the
|
|
13
14
|
// handler layer:
|
|
@@ -36,11 +37,15 @@ interface Reply { ok?: boolean; error?: string; [k: string]: unknown; }
|
|
|
36
37
|
function cleanup() {
|
|
37
38
|
for (const n of [NET_A, NET_B]) {
|
|
38
39
|
try { db.run("DELETE FROM node_stop_requests WHERE network_id = ?1", [n]); } catch {}
|
|
40
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [n]); } catch {}
|
|
39
41
|
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
|
|
40
42
|
try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {}
|
|
41
43
|
// PR1.2a restart_node tests leave node_config_updates rows; clear
|
|
42
44
|
// so the next test's restart_node doesn't see them as in_flight.
|
|
43
45
|
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {}
|
|
46
|
+
// #2022 seeds tasks rows whose status drives the in-flight join; clear
|
|
47
|
+
// them by network or a later test inherits a stale terminal row.
|
|
48
|
+
try { db.run("DELETE FROM tasks WHERE network_id = ?1", [n]); } catch {}
|
|
44
49
|
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
|
|
45
50
|
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
|
|
46
51
|
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
|
|
@@ -179,6 +184,12 @@ function setupAlphaNetwork() {
|
|
|
179
184
|
seedMembership(USER_A_ID, NET_A, "admin");
|
|
180
185
|
seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
|
|
181
186
|
seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
|
|
187
|
+
// Board #625: happy paths must carry actual creation authority, not merely a node_ id.
|
|
188
|
+
db.run(
|
|
189
|
+
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
190
|
+
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
191
|
+
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
192
|
+
);
|
|
182
193
|
}
|
|
183
194
|
|
|
184
195
|
// ── stop_node ──────────────────────────────────────────────────────
|
|
@@ -408,6 +419,109 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
|
|
|
408
419
|
expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
|
|
409
420
|
});
|
|
410
421
|
|
|
422
|
+
// Daemon-mediated delete used to drop only the nodes row. The child's sessions
|
|
423
|
+
// row stayed behind as "offline", so /api/status — and every client list built
|
|
424
|
+
// on it — kept showing the deleted node forever (desktop: the node stays put
|
|
425
|
+
// under 「删除请求已提交」). REST DELETE /api/nodes/:id already drops both.
|
|
426
|
+
test("daemon ack 'stopped' on delete branch → the child's session row is gone too", async () => {
|
|
427
|
+
setupAlphaNetwork();
|
|
428
|
+
db.run(
|
|
429
|
+
"INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
|
|
430
|
+
["resume-sd-child-a", CHILD_A_ALIAS, CHILD_A_ID, NET_A],
|
|
431
|
+
);
|
|
432
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
433
|
+
const dispatch = await call(userTools.delete_node, {
|
|
434
|
+
child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
|
|
435
|
+
});
|
|
436
|
+
expect(dispatch.ok).toBe(true);
|
|
437
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
438
|
+
await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
|
|
439
|
+
const left = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE node_id = ?1", CHILD_A_ID);
|
|
440
|
+
expect(Number(left?.n ?? 0)).toBe(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("delete finalize drops the session by node_id, sparing a same-alias rebuilt node", async () => {
|
|
444
|
+
setupAlphaNetwork();
|
|
445
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
446
|
+
const dispatch = await call(userTools.delete_node, {
|
|
447
|
+
child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
|
|
448
|
+
});
|
|
449
|
+
expect(dispatch.ok).toBe(true);
|
|
450
|
+
// Before the ack lands, a new node takes the same alias and registers.
|
|
451
|
+
const REBUILT_ID = "node_sd_child_a_rebuilt";
|
|
452
|
+
db.run(
|
|
453
|
+
"INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
|
|
454
|
+
["resume-sd-child-a-rebuilt", CHILD_A_ALIAS, REBUILT_ID, NET_A],
|
|
455
|
+
);
|
|
456
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
457
|
+
await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
|
|
458
|
+
const rebuilt = db.get<{ status: string }>("SELECT status FROM sessions WHERE node_id = ?1", REBUILT_ID);
|
|
459
|
+
expect(rebuilt?.status).toBe("idle");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
|
|
463
|
+
// 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
|
|
464
|
+
// f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
|
|
465
|
+
test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
|
|
466
|
+
setupAlphaNetwork();
|
|
467
|
+
// setupAlphaNetwork carries the creation authority.
|
|
468
|
+
// 一个同网、存在、但没创建 CHILD_A 的 daemon
|
|
469
|
+
const DAEMON_B = "node_sd_daemon_b";
|
|
470
|
+
seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
|
|
471
|
+
const u = buildHandlers(USER_A_ID);
|
|
472
|
+
// 错 daemon → mismatch(stop + delete 都拦)。改前会放行、路由到错 daemon。
|
|
473
|
+
expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, network_id: NET_A })).error).toBe("daemon_child_mismatch");
|
|
474
|
+
expect((await call(u.delete_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_B, confirm_alias: CHILD_A_ALIAS, network_id: NET_A })).error).toBe("daemon_child_mismatch");
|
|
475
|
+
// 正确 daemon → 放行(回归钉)
|
|
476
|
+
expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// #571 — 手工起的节点(id `n_…`)没有任何 daemon 链接。改前:authority==null 时
|
|
480
|
+
// 放行调用方点名的任意同网 daemon → 门铃发过去,那台 daemon 本地无记录,按 alias
|
|
481
|
+
// 全机 pgrep+SIGTERM,误杀同 alias 的别的 agent-node。改后:不论传没传 daemon 一律拒。
|
|
482
|
+
test("#571: stop/delete of a hand-started node refuse even with an explicit same-network daemon", async () => {
|
|
483
|
+
setupAlphaNetwork();
|
|
484
|
+
const HAND_ID = "n_571abcde";
|
|
485
|
+
const HAND_ALIAS = "sd-hand-571";
|
|
486
|
+
seedChild(NET_A, HAND_ID, HAND_ALIAS, USER_A_ID, "tok_sd_hand_571");
|
|
487
|
+
const u = buildHandlers(USER_A_ID);
|
|
488
|
+
const s = await call(u.stop_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
|
|
489
|
+
expect(s.ok).toBe(false);
|
|
490
|
+
expect(s.error).toBe("not_daemon_managed");
|
|
491
|
+
expect(String(s.message)).toContain("anet node stop");
|
|
492
|
+
const d = await call(u.delete_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, confirm_alias: HAND_ALIAS, network_id: NET_A });
|
|
493
|
+
expect(d.error).toBe("not_daemon_managed");
|
|
494
|
+
// 没有任何副作用:状态没动、没写 stop 请求行
|
|
495
|
+
expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
|
|
496
|
+
const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
|
|
497
|
+
expect(Number(rows?.n ?? 0)).toBe(0);
|
|
498
|
+
// 回归钉:setupAlphaNetwork 中有 create 记录的 child 照常可停。
|
|
499
|
+
expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
|
|
503
|
+
// name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
|
|
504
|
+
// token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
|
|
505
|
+
test("#1448 finding-5: delete revokes by token_id, spares a same-alias rebuilt token", async () => {
|
|
506
|
+
setupAlphaNetwork();
|
|
507
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
508
|
+
const dispatch = await call(userTools.delete_node, {
|
|
509
|
+
child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
|
|
510
|
+
});
|
|
511
|
+
expect(dispatch.ok).toBe(true);
|
|
512
|
+
// 派发后、ack 前:同 alias 节点被重建,拿到**同名**新 ntok(不同 token_id,仍 active)。
|
|
513
|
+
const REBUILT_TOK = "tok_sd_child_a_rebuilt";
|
|
514
|
+
db.run(
|
|
515
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
516
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
517
|
+
[REBUILT_TOK, USER_A_ID, NET_A, `node:${CHILD_A_ALIAS}`, `hash_${REBUILT_TOK}`],
|
|
518
|
+
);
|
|
519
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
520
|
+
await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
|
|
521
|
+
expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull(); // 原 token 撤销(精确命中)
|
|
522
|
+
expect(readToken(REBUILT_TOK)?.revoked_at).toBeNull(); // 重建的同名新 token 未被误撤(改前按 name 广撤会红)
|
|
523
|
+
});
|
|
524
|
+
|
|
411
525
|
test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => {
|
|
412
526
|
setupAlphaNetwork();
|
|
413
527
|
const userTools = buildHandlers(USER_A_ID);
|
|
@@ -676,6 +790,135 @@ describe("list_my_children HANDLER (RFC-027 PR1.1)", () => {
|
|
|
676
790
|
});
|
|
677
791
|
});
|
|
678
792
|
|
|
793
|
+
describe("create_node audit wording (#1362)", () => {
|
|
794
|
+
test("create_node records a dispatch attempt, not guaranteed SSE delivery", async () => {
|
|
795
|
+
setupAlphaNetwork();
|
|
796
|
+
const tools = buildHandlers(USER_A_ID);
|
|
797
|
+
const r = await call(tools.create_node, {
|
|
798
|
+
daemon_node_id: DAEMON_A_ID,
|
|
799
|
+
node_spec: {
|
|
800
|
+
name: "sd-new-child",
|
|
801
|
+
runtime: "claude-agent-sdk",
|
|
802
|
+
model: "x",
|
|
803
|
+
},
|
|
804
|
+
network_id: NET_A,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(r.ok).toBe(true);
|
|
808
|
+
expect(typeof r.request_id).toBe("string");
|
|
809
|
+
expect(readAudit("create_node_dispatch_attempted", NET_A).length).toBe(1);
|
|
810
|
+
expect(readAudit("create_node_dispatched", NET_A).length).toBe(0);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("list_my_pending_create_requests HANDLER (#1362 SSE reconnect compensation)", () => {
|
|
815
|
+
test("daemon-bound caller gets only its own pending create requests", async () => {
|
|
816
|
+
setupAlphaNetwork();
|
|
817
|
+
const otherDaemonId = "node_sd_daemon_other";
|
|
818
|
+
seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
|
|
819
|
+
db.run(
|
|
820
|
+
`INSERT INTO node_create_requests
|
|
821
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
822
|
+
VALUES
|
|
823
|
+
('cr_pending_mine', ?1, 'child-pending-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?3, 'tok_test'),
|
|
824
|
+
('cr_delivered_mine', ?1, 'child-delivered-mine', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'delivered', NULL, ?4, 'tok_test'),
|
|
825
|
+
('cr_pending_other', ?5, 'child-pending-other', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'pending', NULL, ?6, 'tok_test')`,
|
|
826
|
+
[DAEMON_A_ID, NET_A, Date.now(), Date.now() + 1, otherDaemonId, Date.now() + 2],
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
830
|
+
const r = await call(daemonTools.list_my_pending_create_requests, {});
|
|
831
|
+
|
|
832
|
+
expect(r.ok).toBe(true);
|
|
833
|
+
expect(r.count).toBe(1);
|
|
834
|
+
expect(r.requests).toEqual([
|
|
835
|
+
expect.objectContaining({ request_id: "cr_pending_mine", child_name: "child-pending-mine" }),
|
|
836
|
+
]);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("non-daemon caller (utok) refused", async () => {
|
|
840
|
+
setupAlphaNetwork();
|
|
841
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
842
|
+
const r = await call(userTools.list_my_pending_create_requests, {});
|
|
843
|
+
expect(r.ok).toBe(false);
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
describe("list_my_pending_lifecycle_requests HANDLER (#1448 SSE reconnect compensation)", () => {
|
|
848
|
+
function seedStop(reqId: string, daemonId: string, status: string, action: string, ageMs: number) {
|
|
849
|
+
db.run(
|
|
850
|
+
`INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
|
|
851
|
+
child_alias, action, delete_config, created_by_token, status, created_at)
|
|
852
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 'tok_test', ?7, ?8)`,
|
|
853
|
+
[reqId, NET_A, daemonId, CHILD_A_ID, CHILD_A_ALIAS, action, status, Date.now() - ageMs],
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
// child_node_id parameterized: node_start_requests has a partial-unique
|
|
857
|
+
// index on child_node_id WHERE status IN ('pending','delivered'), so the
|
|
858
|
+
// "mine" and "other" pending start rows must target different children.
|
|
859
|
+
function seedStart(reqId: string, daemonId: string, childId: string, status: string, ageMs: number) {
|
|
860
|
+
db.run(
|
|
861
|
+
`INSERT INTO node_start_requests (request_id, network_id, daemon_node_id, child_node_id,
|
|
862
|
+
child_alias, created_by_token, status, created_at)
|
|
863
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'tok_test', ?6, ?7)`,
|
|
864
|
+
[reqId, NET_A, daemonId, childId, CHILD_A_ALIAS, status, Date.now() - ageMs],
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
test("daemon-bound caller gets only its own PENDING stop/delete + start (excludes delivered + other daemon)", async () => {
|
|
869
|
+
setupAlphaNetwork();
|
|
870
|
+
const otherDaemonId = "node_sd_daemon_other";
|
|
871
|
+
seedDaemon(NET_A, otherDaemonId, "sd-daemon-other", USER_A_ID, "tok_sd_daemon_other");
|
|
872
|
+
// mine — should be returned (stop + delete, oldest first)
|
|
873
|
+
seedStop("sr_pending_stop", DAEMON_A_ID, "pending", "stop", 30);
|
|
874
|
+
seedStop("sr_pending_delete", DAEMON_A_ID, "pending", "delete", 20);
|
|
875
|
+
seedStart("str_pending_mine", DAEMON_A_ID, CHILD_A_ID, "pending", 25);
|
|
876
|
+
// excluded — delivered (already pulled) + other daemon's pending
|
|
877
|
+
seedStop("sr_delivered_mine", DAEMON_A_ID, "delivered", "stop", 40);
|
|
878
|
+
seedStop("sr_pending_other", otherDaemonId, "pending", "stop", 10);
|
|
879
|
+
seedStart("str_pending_other", otherDaemonId, "node_sd_child_other", "pending", 10);
|
|
880
|
+
|
|
881
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
882
|
+
const res = await call(daemonTools.list_my_pending_lifecycle_requests, {});
|
|
883
|
+
expect(res.ok).toBe(true);
|
|
884
|
+
expect(res.stop_count).toBe(2);
|
|
885
|
+
expect((res.stop_requests as Array<{ request_id: string }>).map(x => x.request_id))
|
|
886
|
+
.toEqual(["sr_pending_stop", "sr_pending_delete"]); // oldest-first, action preserved
|
|
887
|
+
expect(res.start_count).toBe(1);
|
|
888
|
+
expect((res.start_requests as Array<{ request_id: string }>).map(x => x.request_id))
|
|
889
|
+
.toEqual(["str_pending_mine"]);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// Witnessed-red 的核心链:真实 stop_node 派发时 daemon 无 SSE 订阅者 →
|
|
893
|
+
// pushEvent 静默丢门铃 → 行卡 pending / 节点卡 stopping(缺陷态)。修复后这条
|
|
894
|
+
// pending 行会被 list_my_pending_lifecycle_requests 列出 → 重连 reconcile 能重放。
|
|
895
|
+
// 改前(无此工具)该行永远无人发现、节点永久卡 stopping。
|
|
896
|
+
test("stuck-pending after a dropped doorbell IS surfaced for reconnect replay", async () => {
|
|
897
|
+
setupAlphaNetwork();
|
|
898
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
899
|
+
// 无 SSE 订阅者:dispatch 建 pending 行 + pushEvent 丢弃(测试进程里没有 client)
|
|
900
|
+
const disp = await call(userTools.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
|
|
901
|
+
expect(disp.ok).toBe(true);
|
|
902
|
+
const reqId = disp.request_id as string;
|
|
903
|
+
// 缺陷态:行 pending、节点 stopping(门铃没送达,无人推进)
|
|
904
|
+
const row = db.get<{ status: string }>("SELECT status FROM node_stop_requests WHERE request_id = ?1", reqId);
|
|
905
|
+
expect(row?.status).toBe("pending");
|
|
906
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
|
|
907
|
+
// 修复:pending 行经新工具对本 daemon 可见 → 重连补偿能拿到并重放
|
|
908
|
+
const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
|
|
909
|
+
const r = await call(daemonTools.list_my_pending_lifecycle_requests, {});
|
|
910
|
+
expect(r.ok).toBe(true);
|
|
911
|
+
expect((r.stop_requests as Array<{ request_id: string }>).map(x => x.request_id)).toContain(reqId);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("non-daemon caller (utok) refused", async () => {
|
|
915
|
+
setupAlphaNetwork();
|
|
916
|
+
const userTools = buildHandlers(USER_A_ID);
|
|
917
|
+
const r = await call(userTools.list_my_pending_lifecycle_requests, {});
|
|
918
|
+
expect(r.ok).toBe(false);
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
679
922
|
// ─── PR1.2a — restart_node resets lifecycle_state ──────────────────
|
|
680
923
|
//
|
|
681
924
|
// 通信龙 #346 ack latent: without this, a stopped node restart-ed via
|
|
@@ -718,3 +961,268 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
|
|
|
718
961
|
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
719
962
|
});
|
|
720
963
|
});
|
|
964
|
+
|
|
965
|
+
// ── #1286 stale-deleting 重派出口 ──────────────────────────────────
|
|
966
|
+
//
|
|
967
|
+
// 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
|
|
968
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
969
|
+
// 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
|
|
970
|
+
describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
|
|
971
|
+
function seedDeletingChild() {
|
|
972
|
+
setupAlphaNetwork();
|
|
973
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
|
|
974
|
+
}
|
|
975
|
+
function seedStopRequest(status: string, ageMs: number) {
|
|
976
|
+
db.run(
|
|
977
|
+
`INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
|
|
978
|
+
child_alias, action, delete_config, created_by_token, status, created_at)
|
|
979
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
|
|
980
|
+
[`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
|
|
981
|
+
CHILD_A_ALIAS, status, Date.now() - ageMs],
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
|
|
985
|
+
|
|
986
|
+
test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
|
|
987
|
+
seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
|
|
988
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, base);
|
|
989
|
+
expect(r.ok).toBe(false);
|
|
990
|
+
expect(r.error).toBe("node_already_deleting");
|
|
991
|
+
expect(typeof r.hint).toBe("string");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
|
|
995
|
+
seedDeletingChild(); seedStopRequest("pending", 60_000);
|
|
996
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
997
|
+
expect(r.ok).toBe(false);
|
|
998
|
+
expect(r.error).toBe("node_already_deleting");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
|
|
1002
|
+
seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
|
|
1003
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
1004
|
+
expect(r.ok).toBe(false);
|
|
1005
|
+
expect(r.error).toBe("node_already_deleting");
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
|
|
1009
|
+
seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
|
|
1010
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
1011
|
+
expect(r.ok).toBe(true);
|
|
1012
|
+
expect((r.request_id as string).startsWith("sr_")).toBe(true);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test("force + dispatched 晾超 5 分钟:同样放行", async () => {
|
|
1016
|
+
seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
|
|
1017
|
+
const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
|
|
1018
|
+
expect(r.ok).toBe(true);
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ── #2022 — in-flight gate: terminal / aged rows must not pin busy ──
|
|
1023
|
+
//
|
|
1024
|
+
// The old gate was COUNT(*) FROM inbox WHERE session_name=? AND acked=0:
|
|
1025
|
+
// no join to tasks, no age limit, and nothing acks an inbox row whose
|
|
1026
|
+
// task already died (retention sweeps only acked=1 rows), so a single
|
|
1027
|
+
// dead row pinned the node to node_busy_in_flight forever.
|
|
1028
|
+
//
|
|
1029
|
+
// Rule under test (#2029 review):
|
|
1030
|
+
// 1. type='task' rows that join to a tasks row are judged by status
|
|
1031
|
+
// ALONE — terminal (replied / failed / cancelled / expired) is not
|
|
1032
|
+
// counted, anything else is counted with NO age ceiling. agent-node
|
|
1033
|
+
// acks the inbox row only when the task is done, so a 90-minute
|
|
1034
|
+
// (or ttl_seconds>3600) run stays acked=0 the whole time and must
|
|
1035
|
+
// keep the gate closed; expiry is patrolExpiredTasks' job.
|
|
1036
|
+
// Join key is COALESCE(task_id, id), the logical id cancel_task
|
|
1037
|
+
// uses.
|
|
1038
|
+
// 2. rows with no status of their own — task rows whose tasks row is
|
|
1039
|
+
// gone, and non-task rows (reply / broadcast / message) — get the
|
|
1040
|
+
// 60-minute age ceiling; nothing else will ever retire them.
|
|
1041
|
+
// 3. patrolExpiredTasks acks the matching inbox rows in the same
|
|
1042
|
+
// transaction that expires the task.
|
|
1043
|
+
|
|
1044
|
+
function seedTaskWithInbox(
|
|
1045
|
+
net: string,
|
|
1046
|
+
alias: string,
|
|
1047
|
+
opts: {
|
|
1048
|
+
task_id: string;
|
|
1049
|
+
status: string;
|
|
1050
|
+
/** signed datetime offset for the tasks row, e.g. '-3 days' */
|
|
1051
|
+
task_age?: string;
|
|
1052
|
+
/** signed datetime offset for the inbox row, e.g. '-3 days' */
|
|
1053
|
+
inbox_age?: string;
|
|
1054
|
+
/** signed datetime offset for tasks.expires_at, e.g. '+1 hour' */
|
|
1055
|
+
expires_in?: string;
|
|
1056
|
+
},
|
|
1057
|
+
) {
|
|
1058
|
+
db.run(
|
|
1059
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
|
|
1060
|
+
created_at, delivered_at, expires_at, network_id)
|
|
1061
|
+
VALUES (?1, 'caller', ?2, 'normal', ?3, 'do the thing', 'reply',
|
|
1062
|
+
datetime('now', ?4), datetime('now', ?4), datetime('now', ?5), ?6)`,
|
|
1063
|
+
[opts.task_id, alias, opts.status, opts.task_age ?? "0 seconds", opts.expires_in ?? "+1 hour", net],
|
|
1064
|
+
);
|
|
1065
|
+
db.run(
|
|
1066
|
+
`INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id, acked, created_at)
|
|
1067
|
+
VALUES (?1, ?1, ?2, 'task', 'normal', 'do the thing', 'caller', ?3, 0, datetime('now', ?4))`,
|
|
1068
|
+
[opts.task_id, alias, net, opts.inbox_age ?? "0 seconds"],
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function stopArgs() {
|
|
1073
|
+
return { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
describe("#2022 stop gate — terminal task rows are not in flight", () => {
|
|
1077
|
+
test("28-day-old expired + 3-day-old replied rows → stop dispatches, in_flight=0", async () => {
|
|
1078
|
+
setupAlphaNetwork();
|
|
1079
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1080
|
+
task_id: "task_2022_expired", status: "expired",
|
|
1081
|
+
task_age: "-28 days", inbox_age: "-28 days", expires_in: "-27 days",
|
|
1082
|
+
});
|
|
1083
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1084
|
+
task_id: "task_2022_replied", status: "replied",
|
|
1085
|
+
task_age: "-3 days", inbox_age: "-3 days", expires_in: "-2 days",
|
|
1086
|
+
});
|
|
1087
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1088
|
+
expect(r.ok).toBe(true);
|
|
1089
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1090
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping");
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("FRESH terminal row (replied, 0s old) also not counted — terminal filter alone is enough", async () => {
|
|
1094
|
+
setupAlphaNetwork();
|
|
1095
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_fresh_replied", status: "replied" });
|
|
1096
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1097
|
+
expect(r.ok).toBe(true);
|
|
1098
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
describe("#2022 stop gate — genuinely in-flight rows still block", () => {
|
|
1103
|
+
test("fresh non-terminal task row → node_busy_in_flight, count surfaced, state untouched", async () => {
|
|
1104
|
+
setupAlphaNetwork();
|
|
1105
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, { task_id: "task_2022_live", status: "delivered" });
|
|
1106
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1107
|
+
expect(r.ok).toBe(false);
|
|
1108
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1109
|
+
expect(r.in_flight_count).toBe(1);
|
|
1110
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1111
|
+
expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("fresh non-task inbox row (type='reply') still counts — judged by age only", async () => {
|
|
1115
|
+
setupAlphaNetwork();
|
|
1116
|
+
db.run(
|
|
1117
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1118
|
+
VALUES (?1, ?2, 'reply', 'fresh reply', 'caller', ?3, 0, datetime('now'))`,
|
|
1119
|
+
[`inbox_2022_fresh_reply`, CHILD_A_ALIAS, NET_A],
|
|
1120
|
+
);
|
|
1121
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1122
|
+
expect(r.ok).toBe(false);
|
|
1123
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1124
|
+
expect(r.in_flight_count).toBe(1);
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
describe("#2022 stop gate — a live task is never aged out (status wins over age)", () => {
|
|
1129
|
+
test("🔴 created 2 hours ago + task.status='running' → still node_busy_in_flight", async () => {
|
|
1130
|
+
setupAlphaNetwork();
|
|
1131
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1132
|
+
task_id: "task_2022_running_90m", status: "running",
|
|
1133
|
+
task_age: "-120 minutes", inbox_age: "-120 minutes", expires_in: "-60 minutes",
|
|
1134
|
+
});
|
|
1135
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1136
|
+
expect(r.ok).toBe(false);
|
|
1137
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1138
|
+
expect(r.in_flight_count).toBe(1);
|
|
1139
|
+
expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test("task.status='acked' 2 hours old (patrol never expires it) → still blocked", async () => {
|
|
1143
|
+
setupAlphaNetwork();
|
|
1144
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1145
|
+
task_id: "task_2022_acked_120m", status: "acked",
|
|
1146
|
+
inbox_age: "-120 minutes", expires_in: "+12 hours",
|
|
1147
|
+
});
|
|
1148
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1149
|
+
expect(r.error).toBe("node_busy_in_flight");
|
|
1150
|
+
expect(r.in_flight_count).toBe(1);
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
describe("#2022 stop gate — age ceiling (60 min) for rows with no status", () => {
|
|
1155
|
+
test("orphan task row (tasks row reaped) 8 days old → not counted", async () => {
|
|
1156
|
+
setupAlphaNetwork();
|
|
1157
|
+
db.run(
|
|
1158
|
+
`INSERT INTO inbox (id, task_id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1159
|
+
VALUES (?1, ?1, ?2, 'task', 'orphan task', 'caller', ?3, 0, datetime('now', '-8 days'))`,
|
|
1160
|
+
[`task_2022_reaped`, CHILD_A_ALIAS, NET_A],
|
|
1161
|
+
);
|
|
1162
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1163
|
+
expect(r.ok).toBe(true);
|
|
1164
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("non-task row older than the ceiling → not counted (the production backlog shape)", async () => {
|
|
1168
|
+
setupAlphaNetwork();
|
|
1169
|
+
db.run(
|
|
1170
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1171
|
+
VALUES (?1, ?2, 'broadcast', 'week-old broadcast', 'caller', ?3, 0, datetime('now', '-7 days'))`,
|
|
1172
|
+
[`inbox_2022_old_broadcast`, CHILD_A_ALIAS, NET_A],
|
|
1173
|
+
);
|
|
1174
|
+
const r = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1175
|
+
expect(r.ok).toBe(true);
|
|
1176
|
+
expect(r.in_flight_at_dispatch).toBe(0);
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
describe("#2022 patrolExpiredTasks — expires the task AND acks its inbox rows", () => {
|
|
1181
|
+
test("same transaction: tasks.status='expired' + inbox.acked=1 → gate unblocks", async () => {
|
|
1182
|
+
setupAlphaNetwork();
|
|
1183
|
+
seedTaskWithInbox(NET_A, CHILD_A_ALIAS, {
|
|
1184
|
+
task_id: "task_2022_due", status: "delivered",
|
|
1185
|
+
inbox_age: "-10 minutes", expires_in: "-1 minute",
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// Before the patrol: fresh, non-terminal → the node is correctly busy.
|
|
1189
|
+
const before = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1190
|
+
expect(before.error).toBe("node_busy_in_flight");
|
|
1191
|
+
expect(before.in_flight_count).toBe(1);
|
|
1192
|
+
|
|
1193
|
+
patrolExpiredTasks();
|
|
1194
|
+
|
|
1195
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", "task_2022_due")?.status).toBe("expired");
|
|
1196
|
+
const inboxRow = db.get<{ acked: number }>(
|
|
1197
|
+
"SELECT acked FROM inbox WHERE task_id = ?1 AND session_name = ?2", "task_2022_due", CHILD_A_ALIAS,
|
|
1198
|
+
);
|
|
1199
|
+
expect(inboxRow?.acked).toBe(1);
|
|
1200
|
+
|
|
1201
|
+
// And the gate that was blocked a moment ago now dispatches.
|
|
1202
|
+
const after = await call(buildHandlers(USER_A_ID).stop_node, stopArgs());
|
|
1203
|
+
expect(after.ok).toBe(true);
|
|
1204
|
+
expect(after.in_flight_at_dispatch).toBe(0);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("legacy inbox row keyed by id (task_id IS NULL) is acked too", async () => {
|
|
1208
|
+
setupAlphaNetwork();
|
|
1209
|
+
db.run(
|
|
1210
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response,
|
|
1211
|
+
created_at, expires_at, network_id)
|
|
1212
|
+
VALUES ('task_2022_legacy', 'caller', ?1, 'normal', 'delivered', 'do the thing', 'reply',
|
|
1213
|
+
datetime('now', '-10 minutes'), datetime('now', '-1 minute'), ?2)`,
|
|
1214
|
+
[CHILD_A_ALIAS, NET_A],
|
|
1215
|
+
);
|
|
1216
|
+
// Pre-task_id shape: the transport row IS the logical id.
|
|
1217
|
+
db.run(
|
|
1218
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id, acked, created_at)
|
|
1219
|
+
VALUES ('task_2022_legacy', ?1, 'task', 'do the thing', 'caller', ?2, 0, datetime('now', '-10 minutes'))`,
|
|
1220
|
+
[CHILD_A_ALIAS, NET_A],
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
patrolExpiredTasks();
|
|
1224
|
+
|
|
1225
|
+
expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = 'task_2022_legacy'")?.status).toBe("expired");
|
|
1226
|
+
expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'task_2022_legacy'")?.acked).toBe(1);
|
|
1227
|
+
});
|
|
1228
|
+
});
|