gentle-pi 4.2.0 → 4.4.0
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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -2,8 +2,11 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import fs, { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { syncBuiltinESMExports } from "node:module";
|
|
5
|
-
import {
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
6
7
|
import { PassThrough } from "node:stream";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createSessionScratchExtension } from "../extensions/session-scratch.ts";
|
|
7
10
|
import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
|
|
8
11
|
import { MISSING_TOOLS_NOTE_PREFIX, TASK_STATUS, TaskStore, THREAD_ITEM, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
9
12
|
import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
|
|
@@ -31,7 +34,7 @@ interface Harness {
|
|
|
31
34
|
advance(ms: number): void;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
|
|
37
|
+
function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; cleanupTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; exitOnStdinEnd?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
|
|
35
38
|
const children: FakeChild[] = [];
|
|
36
39
|
const timers: Harness["timers"] = [];
|
|
37
40
|
const asks: Harness["asks"] = [];
|
|
@@ -45,7 +48,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
45
48
|
spawn: (command, args, launchOptions) => {
|
|
46
49
|
if (options.failStart) throw new Error("fixture spawn failed");
|
|
47
50
|
spawnOptions.push({ command, args, env: launchOptions.env, stdio: launchOptions.stdio });
|
|
48
|
-
const fake = fakeChild({ exitOnKill: options.exitOnKill, pid: options.pid });
|
|
51
|
+
const fake = fakeChild({ exitOnKill: options.exitOnKill, exitOnStdinEnd: options.exitOnStdinEnd, pid: options.pid });
|
|
49
52
|
if (options.state !== undefined) {
|
|
50
53
|
fake.child.stdin.removeAllListeners("data");
|
|
51
54
|
fake.child.stdin.on("data", (chunk) => {
|
|
@@ -70,7 +73,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
70
73
|
pi: { command: "pi", args: [] },
|
|
71
74
|
};
|
|
72
75
|
const store = new TaskStore();
|
|
73
|
-
const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs }, deps, {
|
|
76
|
+
const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs }, deps, {
|
|
74
77
|
askUser: async (taskId, ask) => {
|
|
75
78
|
asks.push({ taskId, method: ask.method });
|
|
76
79
|
return options.answer ?? { value: "yes" };
|
|
@@ -92,6 +95,87 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
92
95
|
|
|
93
96
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
94
97
|
|
|
98
|
+
for (const mode of [AGENT_MODE.TASK, AGENT_MODE.BACKGROUND]) {
|
|
99
|
+
for (const policy of [undefined, "off", "malformed"] as const) {
|
|
100
|
+
test(`background capability admission rejects ${mode} with ${policy ?? "default"} policy`, async (t) => {
|
|
101
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
102
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
103
|
+
const config = join(root, "config");
|
|
104
|
+
const file = join(config, "background-subagents.json");
|
|
105
|
+
fs.mkdirSync(config);
|
|
106
|
+
const raw = policy === "malformed" ? "{broken" : JSON.stringify({ schema: "gentle-pi.background-subagents/v1", policy });
|
|
107
|
+
if (policy) fs.writeFileSync(file, raw);
|
|
108
|
+
const h = harness({ maxConcurrency: 1 });
|
|
109
|
+
let launches = 0;
|
|
110
|
+
const env = { GENTLE_PI_CONFIG_HOME: config, ...(policy === "malformed" ? { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } : {}) };
|
|
111
|
+
const task = h.runner.run(request({ cwd: root, env, mode, agent: { ...explorer, tools: ["read", "bash_background"] }, onLaunch: () => { launches++; } }));
|
|
112
|
+
await tick();
|
|
113
|
+
assert.equal(h.store.get(task.id)?.status, TASK_STATUS.FAILED);
|
|
114
|
+
const target = policy ? file : join(root, ".pi", "gentle-ai", "background-subagents.json");
|
|
115
|
+
assert.equal(h.store.get(task.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${target} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
|
|
116
|
+
assert.equal(h.children.length, 0);
|
|
117
|
+
assert.equal(h.spawnOptions.length, 0);
|
|
118
|
+
assert.equal(h.timers.length, 0);
|
|
119
|
+
assert.equal(launches, 0);
|
|
120
|
+
assert.deepEqual(h.finishes, [task.id]);
|
|
121
|
+
assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
|
|
122
|
+
assert.equal(existsSync(file), policy !== undefined);
|
|
123
|
+
if (policy) assert.equal(readFileSync(file, "utf8"), raw);
|
|
124
|
+
const next = h.runner.run(request({ cwd: root, env }));
|
|
125
|
+
await tick();
|
|
126
|
+
assert.equal(h.store.get(next.id)?.status, TASK_STATUS.RUNNING);
|
|
127
|
+
h.runner.cancel(next.id);
|
|
128
|
+
await tick();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const { tools, policy } of [
|
|
134
|
+
...[undefined, "off"].flatMap(policy => [["read", "unknown_tool"], []].map(tools => ({ tools, policy }))),
|
|
135
|
+
{ tools: ["read", "bash_background", "grep"], policy: "on" },
|
|
136
|
+
]) {
|
|
137
|
+
test(`background capability admission preserves launch for ${tools.join(",") || "default tools"} with ${policy ?? "default off"} policy`, async (t) => {
|
|
138
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
139
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
140
|
+
const h = harness();
|
|
141
|
+
const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), ...(policy === undefined ? {} : { GENTLE_PI_BACKGROUND_SUBAGENTS: policy }), KEEP: "yes" };
|
|
142
|
+
const task = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools } }));
|
|
143
|
+
await tick();
|
|
144
|
+
assert.equal(h.store.get(task.id)?.status, TASK_STATUS.RUNNING);
|
|
145
|
+
assert.equal(h.store.get(task.id)?.error, null);
|
|
146
|
+
assert.equal(h.children.length, 1);
|
|
147
|
+
const expected = tools.length ? [...tools, "subagent_parent_message"].join(",") : undefined;
|
|
148
|
+
assert.equal(h.spawnOptions[0].env[REQUESTED_TOOLS_ENV], expected);
|
|
149
|
+
assert.equal(h.spawnOptions[0].env.KEEP, "yes");
|
|
150
|
+
assert.deepEqual(h.spawnOptions[0].args, childArguments(request({ cwd: root, env, agent: { ...explorer, tools } })));
|
|
151
|
+
assert.equal(h.timers.filter(timer => !timer.cancelled).length, 1);
|
|
152
|
+
h.runner.cancel(task.id);
|
|
153
|
+
await tick();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test("background capability admission refreshes project policy when queued work dispatches", async (t) => {
|
|
158
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
159
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
160
|
+
const dir = join(root, ".pi", "gentle-ai");
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
const file = join(dir, "background-subagents.json");
|
|
163
|
+
const h = harness({ maxConcurrency: 1 });
|
|
164
|
+
const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), GENTLE_PI_BACKGROUND_SUBAGENTS: "on" };
|
|
165
|
+
const first = h.runner.run(request({ cwd: root, env }));
|
|
166
|
+
const queued = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools: ["bash_background"] } }));
|
|
167
|
+
await tick();
|
|
168
|
+
assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.QUEUED);
|
|
169
|
+
const raw = '{"schema":"gentle-pi.background-subagents/v1","policy":"off"}';
|
|
170
|
+
fs.writeFileSync(file, raw);
|
|
171
|
+
h.runner.cancel(first.id);
|
|
172
|
+
await tick();
|
|
173
|
+
assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.FAILED);
|
|
174
|
+
assert.equal(h.store.get(queued.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${file} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
|
|
175
|
+
assert.equal(h.children.length, 1);
|
|
176
|
+
assert.equal(readFileSync(file, "utf8"), raw);
|
|
177
|
+
});
|
|
178
|
+
|
|
95
179
|
const FOUR_MIN_MS = 4 * 60_000;
|
|
96
180
|
|
|
97
181
|
function argumentUpdate(type: string, fields: Record<string, unknown> = {}): Record<string, unknown> {
|
|
@@ -618,6 +702,21 @@ test("runner captures resolved model and effort, retaining omitted launch values
|
|
|
618
702
|
h.runner.cancel(task.id);
|
|
619
703
|
});
|
|
620
704
|
|
|
705
|
+
test("record creation copies the launch-layer model source next to the model (#2180)", () => {
|
|
706
|
+
const sourced = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
|
|
707
|
+
const sourcedTask = sourced.runner.run(request({ modelSource: "binding" }));
|
|
708
|
+
assert.equal(sourced.store.get(sourcedTask.id)?.modelSource, "binding", "the record keeps the host-computed layer next to the resolved model");
|
|
709
|
+
sourced.runner.cancel(sourcedTask.id);
|
|
710
|
+
|
|
711
|
+
// The absence contract: a request without a model source (older callers,
|
|
712
|
+
// peers) records no field at all, so the card renders exactly as before.
|
|
713
|
+
const plain = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
|
|
714
|
+
const plainTask = plain.runner.run(request());
|
|
715
|
+
assert.equal(plain.store.get(plainTask.id)?.modelSource, undefined);
|
|
716
|
+
assert.equal("modelSource" in (plain.store.get(plainTask.id) as TaskRecord), false, "no modelSource key is recorded when the launch did not name a layer");
|
|
717
|
+
plain.runner.cancel(plainTask.id);
|
|
718
|
+
});
|
|
719
|
+
|
|
621
720
|
test("runner delivers each response combination once at finish, never attributing launch selection", async () => {
|
|
622
721
|
const snapshots: NonNullable<Parameters<NonNullable<RunnerHooks["onFinish"]>>[1]>[] = [];
|
|
623
722
|
const h = harness({ exitOnKill: false, state: { model: { provider: "anthropic", id: "launch" }, thinkingLevel: "max" },
|
|
@@ -905,7 +1004,8 @@ test("AgentRunner runs a task end to end: prompt, deltas into the store, complet
|
|
|
905
1004
|
assert.equal(finished?.toolCalls, 1);
|
|
906
1005
|
assert.equal(finished?.sessionPath, "/sessions/child.jsonl");
|
|
907
1006
|
assert.equal(finished?.label, "Map the repo");
|
|
908
|
-
assert.ok(children[0].
|
|
1007
|
+
assert.ok(children[0].child.stdin.writableEnded, "the child is stopped via orderly shutdown once the answer is in");
|
|
1008
|
+
assert.equal(children[0].killed.length, 0, "no kill signals sent during orderly settlement");
|
|
909
1009
|
assert.equal(store.thread(task.id).items.length, 2);
|
|
910
1010
|
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
|
|
911
1011
|
});
|
|
@@ -955,6 +1055,143 @@ test("AgentRunner queues beyond max concurrency and starts the next task when on
|
|
|
955
1055
|
assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING);
|
|
956
1056
|
});
|
|
957
1057
|
|
|
1058
|
+
test("AgentRunner requests orderly shutdown via stdin on settlement and does not send SIGTERM immediately (#1740)", async () => {
|
|
1059
|
+
const { store, runner, children } = harness({ exitOnKill: false });
|
|
1060
|
+
const task = runner.run(request());
|
|
1061
|
+
await tick();
|
|
1062
|
+
assert.equal(children.length, 1);
|
|
1063
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1064
|
+
await tick();
|
|
1065
|
+
assert.equal(children[0].killed.length, 0);
|
|
1066
|
+
children[0].emit({ type: "agent_settled" });
|
|
1067
|
+
await tick();
|
|
1068
|
+
|
|
1069
|
+
// Orderly shutdown must have requested stdin.end()
|
|
1070
|
+
assert.equal(children[0].child.stdin.writableEnded, true, "stdin must be ended on settlement to trigger Pi orderly shutdown");
|
|
1071
|
+
// Must NOT send SIGTERM immediately
|
|
1072
|
+
assert.deepEqual(children[0].killed, [], "SIGTERM must not be sent immediately upon settlement");
|
|
1073
|
+
// Task completion and cleanup in flight should be distinguishable
|
|
1074
|
+
assert.equal(store.get(task.id)?.lastStep, "cleaning up", "lastStep distinguishes in-flight cleanup");
|
|
1075
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "task waits for cleanup exit");
|
|
1076
|
+
|
|
1077
|
+
// Child exits cleanly upon session_shutdown completion
|
|
1078
|
+
children[0].exit(0);
|
|
1079
|
+
await tick();
|
|
1080
|
+
await tick();
|
|
1081
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
|
|
1082
|
+
assert.equal(store.get(task.id)?.lastStep, "done");
|
|
1083
|
+
assert.deepEqual(children[0].killed, [], "clean shutdown completes with zero kill signals sent");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("AgentRunner allows delayed cleanup and cancels escalation after clean exit (#1740)", async () => {
|
|
1087
|
+
// EOF must be independent of kill behavior: this child honors signals but
|
|
1088
|
+
// needs time to finish asynchronous shutdown after its input closes.
|
|
1089
|
+
const { store, runner, children, timers, advance, finishes } = harness({ exitOnStdinEnd: false });
|
|
1090
|
+
const task = runner.run(request());
|
|
1091
|
+
await tick();
|
|
1092
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1093
|
+
children[0].emit({ type: "agent_settled" });
|
|
1094
|
+
await tick();
|
|
1095
|
+
assert.equal(children[0].child.stdin.writableEnded, true);
|
|
1096
|
+
advance(2_000);
|
|
1097
|
+
await tick();
|
|
1098
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING);
|
|
1099
|
+
assert.equal(store.get(task.id)?.lastStep, "cleaning up");
|
|
1100
|
+
assert.deepEqual(children[0].killed, []);
|
|
1101
|
+
assert.deepEqual(finishes, []);
|
|
1102
|
+
children[0].exit(0);
|
|
1103
|
+
await tick();
|
|
1104
|
+
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
|
|
1105
|
+
assert.equal(store.get(task.id)?.lastStep, "done");
|
|
1106
|
+
assert.deepEqual(finishes, [task.id]);
|
|
1107
|
+
assert.ok(timers.filter((timer) => timer.ms === 5_000).every((timer) => timer.cancelled));
|
|
1108
|
+
advance(5_000);
|
|
1109
|
+
await tick();
|
|
1110
|
+
assert.deepEqual(children[0].killed, []);
|
|
1111
|
+
assert.deepEqual(finishes, [task.id]);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("AgentRunner escalates to SIGTERM and SIGKILL if orderly cleanup exceeds cleanupTimeoutMs (#1740)", async () => {
|
|
1115
|
+
const { store, runner, children, timers } = harness({ exitOnKill: false, cleanupTimeoutMs: 3000 });
|
|
1116
|
+
const task = runner.run(request());
|
|
1117
|
+
await tick();
|
|
1118
|
+
assert.equal(children.length, 1);
|
|
1119
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1120
|
+
await tick();
|
|
1121
|
+
children[0].emit({ type: "agent_settled" });
|
|
1122
|
+
await tick();
|
|
1123
|
+
|
|
1124
|
+
assert.equal(children[0].child.stdin.writableEnded, true);
|
|
1125
|
+
assert.deepEqual(children[0].killed, []);
|
|
1126
|
+
|
|
1127
|
+
// Find the cleanup timeout timer (3000 ms) and trigger it
|
|
1128
|
+
const cleanupTimer = timers.find((timer) => !timer.cancelled && timer.ms === 3000);
|
|
1129
|
+
assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
|
|
1130
|
+
cleanupTimer.fn();
|
|
1131
|
+
await tick();
|
|
1132
|
+
|
|
1133
|
+
// Escalation has fired SIGTERM
|
|
1134
|
+
assert.deepEqual(children[0].killed, ["SIGTERM"]);
|
|
1135
|
+
|
|
1136
|
+
// And termination grace timer (250 ms) is scheduled for SIGKILL
|
|
1137
|
+
const killTimer = timers.find((timer) => !timer.cancelled && timer.ms === 250);
|
|
1138
|
+
assert.ok(killTimer, "SIGKILL escalation timer must be scheduled");
|
|
1139
|
+
killTimer.fn();
|
|
1140
|
+
await tick();
|
|
1141
|
+
|
|
1142
|
+
assert.deepEqual(children[0].killed, ["SIGTERM", "SIGKILL"]);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
test("orderly cleanup escalation retains the full exit confirmation deadline after SIGKILL (#1740)", async () => {
|
|
1146
|
+
let groupAlive = true;
|
|
1147
|
+
const { store, runner, children, timers, advance } = harness({
|
|
1148
|
+
exitOnKill: false,
|
|
1149
|
+
pid: 42,
|
|
1150
|
+
cleanupTimeoutMs: 5_000,
|
|
1151
|
+
process: {
|
|
1152
|
+
platform: "linux",
|
|
1153
|
+
kill: () => {
|
|
1154
|
+
if (!groupAlive) throw Object.assign(new Error(), { code: "ESRCH" });
|
|
1155
|
+
return true;
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
const task = runner.run(request());
|
|
1160
|
+
await tick();
|
|
1161
|
+
assert.equal(children.length, 1);
|
|
1162
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1163
|
+
await tick();
|
|
1164
|
+
children[0].emit({ type: "agent_settled" });
|
|
1165
|
+
await tick();
|
|
1166
|
+
|
|
1167
|
+
// Advance time by the cleanup window (5000 ms)
|
|
1168
|
+
const cleanupTimer = timers.find((t) => !t.cancelled && t.ms === 5_000);
|
|
1169
|
+
assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
|
|
1170
|
+
advance(5_000);
|
|
1171
|
+
await tick();
|
|
1172
|
+
|
|
1173
|
+
// Advance time by termination grace (250 ms)
|
|
1174
|
+
const killTimer = timers.find((t) => !t.cancelled && t.ms === 250);
|
|
1175
|
+
assert.ok(killTimer, "SIGKILL timer must be scheduled");
|
|
1176
|
+
advance(250);
|
|
1177
|
+
await tick();
|
|
1178
|
+
|
|
1179
|
+
// After SIGKILL, confirmGroupExit checks the process group. Because cleanupDeadlineAt was set
|
|
1180
|
+
// at SIGKILL time, it must NOT immediately fail with capacity quarantined!
|
|
1181
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "must not prematurely quarantine after cleanup window");
|
|
1182
|
+
const probeTimer = timers.find((t) => !t.cancelled && t.ms === 25);
|
|
1183
|
+
assert.ok(probeTimer, "exit confirmation polling timer must be scheduled");
|
|
1184
|
+
|
|
1185
|
+
// If the group exits during the confirmation window:
|
|
1186
|
+
groupAlive = false;
|
|
1187
|
+
children[0].exit(0);
|
|
1188
|
+
advance(25);
|
|
1189
|
+
await tick();
|
|
1190
|
+
|
|
1191
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
|
|
1192
|
+
assert.equal(store.get(task.id)?.error, null);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
958
1195
|
test("AgentRunner classifies terminal assistant outcomes only after settlement", async () => {
|
|
959
1196
|
const scenarios = [
|
|
960
1197
|
{ name: "error", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error: secret=never-copy" }], status: TASK_STATUS.FAILED, error: /assistant reported an error/ },
|
|
@@ -1029,10 +1266,13 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
|
|
|
1029
1266
|
await tick();
|
|
1030
1267
|
const ownedIpc = launches[0]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
|
|
1031
1268
|
assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the runner creates an opaque owned-IPC marker");
|
|
1269
|
+
const scratch = launches[0]?.options.env.TMPDIR;
|
|
1270
|
+
assert.match(basename(scratch ?? ""), /^ga[A-Za-z0-9]{6}$/, "the child gets its own TMPDIR");
|
|
1271
|
+
const scratchEnv = platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
1032
1272
|
assert.deepEqual(launches, [{
|
|
1033
1273
|
command: "pi-fixture",
|
|
1034
1274
|
args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."],
|
|
1035
|
-
options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
|
|
1275
|
+
options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
|
|
1036
1276
|
}]);
|
|
1037
1277
|
child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] });
|
|
1038
1278
|
child.emit({ type: "agent_settled" });
|
|
@@ -1115,7 +1355,10 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
|
|
|
1115
1355
|
const launch = spawnOptions[0];
|
|
1116
1356
|
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
1117
1357
|
assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
|
|
1118
|
-
|
|
1358
|
+
const scratch = launch?.env.TMPDIR;
|
|
1359
|
+
const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
1360
|
+
assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
|
|
1361
|
+
assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
|
|
1119
1362
|
assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
|
|
1120
1363
|
assert.equal(launch?.stdio?.length, 5);
|
|
1121
1364
|
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "channel checked" }], stopReason: "stop" }] });
|
|
@@ -1778,3 +2021,238 @@ test("temporary instructions transport file is cleaned up if child emits an earl
|
|
|
1778
2021
|
assert.ok(!existsSync(capturedPromptPath), "temporary transport file must be cleaned up on early child error");
|
|
1779
2022
|
assert.ok(!existsSync(dirname(capturedPromptPath)), "temporary transport directory must be cleaned up on early child error");
|
|
1780
2023
|
});
|
|
2024
|
+
|
|
2025
|
+
// Harness roadmap T3 (R1): asking the parent to remove a child's scratch failed
|
|
2026
|
+
// in 3 of 6 bench cells, so each child gets its own TMPDIR that the runtime
|
|
2027
|
+
// removes when the child ends.
|
|
2028
|
+
function scratchRunner(spawnImpl: (env: NodeJS.ProcessEnv) => ChildLike) {
|
|
2029
|
+
let clock = 1000;
|
|
2030
|
+
const deps: RunnerDeps = {
|
|
2031
|
+
spawn: (_command, _args, options) => spawnImpl((options as { env: NodeJS.ProcessEnv }).env),
|
|
2032
|
+
now: () => (clock += 1),
|
|
2033
|
+
schedule: (_fn, _ms) => () => {},
|
|
2034
|
+
pi: { command: "pi", args: [] },
|
|
2035
|
+
};
|
|
2036
|
+
return new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, deps, { askUser: async () => ({ value: "yes" }) });
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
test("each child gets its own TMPDIR under the parent's temp dir, removed with its contents on exit", async () => {
|
|
2040
|
+
const fake = fakeChild();
|
|
2041
|
+
let childTmp = "";
|
|
2042
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
|
|
2043
|
+
const task = runner.run(request({ env: { ...process.env, TMPDIR: tmpdir() } }));
|
|
2044
|
+
await tick();
|
|
2045
|
+
assert.ok(childTmp.startsWith(tmpdir()) && childTmp !== tmpdir(), `child TMPDIR ${childTmp} is a fresh dir under ${tmpdir()}`);
|
|
2046
|
+
assert.ok(statSync(childTmp).isDirectory());
|
|
2047
|
+
const scratch = fs.mkdtempSync(`${childTmp}/check-`);
|
|
2048
|
+
fs.writeFileSync(`${scratch}/state.json`, "{}");
|
|
2049
|
+
fake.exit(0);
|
|
2050
|
+
await runner.waitFor(task.id);
|
|
2051
|
+
assert.equal(existsSync(childTmp), false, "the child's TMPDIR and everything mktemp created in it are removed");
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
test("a child TMPDIR is removed when spawn throws", async () => {
|
|
2055
|
+
let childTmp = "";
|
|
2056
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; throw new Error("spawn failed intentionally"); });
|
|
2057
|
+
const task = runner.run(request());
|
|
2058
|
+
await tick();
|
|
2059
|
+
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
|
|
2060
|
+
assert.ok(childTmp, "the failed launch still received a child TMPDIR");
|
|
2061
|
+
assert.equal(existsSync(childTmp), false);
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
test("a quarantined child keeps its TMPDIR, since a descendant may still use it", async () => {
|
|
2065
|
+
const timers: Array<{ fn: () => void; ms: number }> = [];
|
|
2066
|
+
let now = 1_000;
|
|
2067
|
+
let childTmp = "";
|
|
2068
|
+
let fake: FakeChild | undefined;
|
|
2069
|
+
const store = new TaskStore();
|
|
2070
|
+
const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
|
|
2071
|
+
spawn: (_command, _args, options) => { childTmp = (options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? ""; return (fake = fakeChild({ exitOnKill: false, pid: 91 })).child; },
|
|
2072
|
+
now: () => now,
|
|
2073
|
+
schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
|
|
2074
|
+
pi: { command: "pi", args: [] },
|
|
2075
|
+
process: { platform: "win32", kill: () => {} },
|
|
2076
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
2077
|
+
const task = runner.run(request());
|
|
2078
|
+
await tick();
|
|
2079
|
+
runner.cancel(task.id);
|
|
2080
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
2081
|
+
now = 5_000;
|
|
2082
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
2083
|
+
await tick();
|
|
2084
|
+
assert.match(store.get(task.id)?.error ?? "", /capacity quarantined/);
|
|
2085
|
+
assert.equal(existsSync(childTmp), true, "an unconfirmed exit must not delete files a live descendant may hold");
|
|
2086
|
+
fake!.exit(0);
|
|
2087
|
+
await tick();
|
|
2088
|
+
assert.equal(existsSync(childTmp), false, "the proven exit removes the quarantined child's TMPDIR");
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
// R1 review advisory: a quarantined POSIX child whose own exit arrived while a
|
|
2092
|
+
// descendant still held its group was never probed again, so its TMPDIR and
|
|
2093
|
+
// slot stayed for the rest of the session after the group ended.
|
|
2094
|
+
test("a quarantined child whose exit is seen before its group ends frees its TMPDIR and slot once the group is gone", async () => {
|
|
2095
|
+
const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
|
|
2096
|
+
let now = 1_000;
|
|
2097
|
+
let groupAlive = true;
|
|
2098
|
+
let launches = 0;
|
|
2099
|
+
const childTmps: string[] = [];
|
|
2100
|
+
const store = new TaskStore();
|
|
2101
|
+
let fake: FakeChild | undefined;
|
|
2102
|
+
const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
|
|
2103
|
+
spawn: (_command, _args, options) => {
|
|
2104
|
+
childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
|
|
2105
|
+
return (fake = fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) })).child;
|
|
2106
|
+
},
|
|
2107
|
+
now: () => now,
|
|
2108
|
+
schedule: (fn, ms) => {
|
|
2109
|
+
const timer = { fn, ms, cancelled: false };
|
|
2110
|
+
timers.push(timer);
|
|
2111
|
+
return () => { timer.cancelled = true; };
|
|
2112
|
+
},
|
|
2113
|
+
pi: { command: "pi", args: [] },
|
|
2114
|
+
process: { platform: "linux", kill: (_pid, signal) => {
|
|
2115
|
+
if (signal === 0 && !groupAlive) throw Object.assign(new Error("group probe"), { code: "ESRCH" });
|
|
2116
|
+
} },
|
|
2117
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
2118
|
+
const first = runner.run(request());
|
|
2119
|
+
const second = runner.run(request({ prompt: "queued" }));
|
|
2120
|
+
await tick();
|
|
2121
|
+
const quarantined = fake!;
|
|
2122
|
+
runner.cancel(first.id);
|
|
2123
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
2124
|
+
now = 5_000;
|
|
2125
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
2126
|
+
await tick();
|
|
2127
|
+
assert.match(store.get(first.id)?.error ?? "", /capacity quarantined/);
|
|
2128
|
+
quarantined.exit(0);
|
|
2129
|
+
await tick();
|
|
2130
|
+
assert.equal(existsSync(childTmps[0]), true, "a descendant still holds the group, so the TMPDIR stays");
|
|
2131
|
+
assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "the slot stays quarantined while the group lives");
|
|
2132
|
+
groupAlive = false;
|
|
2133
|
+
const probe = timers.filter((timer) => !timer.cancelled && timer.ms === 1_000).at(-1);
|
|
2134
|
+
assert.ok(probe, "the observed exit keeps probing the remaining group");
|
|
2135
|
+
probe.fn();
|
|
2136
|
+
await tick();
|
|
2137
|
+
assert.equal(existsSync(childTmps[0]), false, "the gone group removes the quarantined child's TMPDIR");
|
|
2138
|
+
assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING, "the freed slot starts queued work");
|
|
2139
|
+
assert.equal(store.get(first.id)?.error, `process cleanup unconfirmed after 1000ms; capacity quarantined`, "the recorded outcome is unchanged");
|
|
2140
|
+
runner.cancel(second.id);
|
|
2141
|
+
fake!.exit(0);
|
|
2142
|
+
await tick();
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
test("a relative inherited TMPDIR falls back to the system temp dir, so the child's path is absolute", async () => {
|
|
2146
|
+
const fake = fakeChild();
|
|
2147
|
+
let childTmp = "";
|
|
2148
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
|
|
2149
|
+
const task = runner.run(request({ env: { TMPDIR: "reltmp" } }));
|
|
2150
|
+
await tick();
|
|
2151
|
+
assert.ok(childTmp.startsWith(join(tmpdir(), "ga")), childTmp);
|
|
2152
|
+
fake.exit(0);
|
|
2153
|
+
await runner.waitFor(task.id);
|
|
2154
|
+
assert.equal(existsSync(childTmp), false);
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
// odd/tasks/session-scratch-ledger.md S9: TMPDIR nests the session scratch and the
|
|
2158
|
+
// subagent's own TMPDIR, and a Unix socket path (104 bytes with the NUL on macOS)
|
|
2159
|
+
// under it must still fit, as Python's `pymp-XXXXXXXX/listener-XXXXXXXX` (32 bytes).
|
|
2160
|
+
test("S9: under a 48-byte inherited TMPDIR, a subagent TMPDIR leaves room for a 32-byte socket name", async (t) => {
|
|
2161
|
+
const INHERITED_BYTES = 48;
|
|
2162
|
+
const SOCKET_NAME = "/pymp-XXXXXXXX/listener-XXXXXXXX";
|
|
2163
|
+
// An unresolved parent, like macOS `/var/folders/xx/<30 chars>/T`, padded to 48 bytes.
|
|
2164
|
+
const parent = [tmpdir(), "/tmp"].map((root) => { try { return fs.mkdtempSync(join(root, "b")); } catch { return undefined; } })
|
|
2165
|
+
.find((dir): dir is string => dir !== undefined && Buffer.byteLength(dir) + 2 <= INHERITED_BYTES);
|
|
2166
|
+
if (!parent) { t.skip("no temp directory short enough to build a 48-byte path"); return; }
|
|
2167
|
+
t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
|
|
2168
|
+
const inherited = join(parent, "x".repeat(INHERITED_BYTES - Buffer.byteLength(parent) - 1));
|
|
2169
|
+
fs.mkdirSync(inherited);
|
|
2170
|
+
assert.equal(Buffer.byteLength(inherited), INHERITED_BYTES);
|
|
2171
|
+
const env: NodeJS.ProcessEnv = { TMPDIR: inherited, GENTLE_PI_CONFIG_HOME: join(parent, "config") };
|
|
2172
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
2173
|
+
createSessionScratchExtension({ env })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
|
|
2174
|
+
const ctx = { cwd: parent, hasUI: false, sessionManager: { getSessionId: () => "a-session-id-much-longer-than-the-scratch-name" } };
|
|
2175
|
+
handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx);
|
|
2176
|
+
t.after(() => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx));
|
|
2177
|
+
assert.equal(dirname(env.TMPDIR!), inherited, "the session scratch keeps the inherited spelling");
|
|
2178
|
+
const fake = fakeChild();
|
|
2179
|
+
let childTmp = "";
|
|
2180
|
+
const runner = scratchRunner((childEnv) => { childTmp = childEnv.TMPDIR ?? ""; return fake.child; });
|
|
2181
|
+
const task = runner.run(request({ env: { TMPDIR: env.TMPDIR } }));
|
|
2182
|
+
await tick();
|
|
2183
|
+
assert.equal(dirname(childTmp), env.TMPDIR, "the subagent TMPDIR nests under the session scratch");
|
|
2184
|
+
const socketPath = Buffer.byteLength(`${childTmp}${SOCKET_NAME}`);
|
|
2185
|
+
assert.ok(socketPath <= 103, `${childTmp}${SOCKET_NAME} is ${socketPath} bytes`);
|
|
2186
|
+
fake.exit(0);
|
|
2187
|
+
await runner.waitFor(task.id);
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
// odd/tasks/scratch-review-advisories.md S2 (native review advisories of #2300):
|
|
2191
|
+
// session shutdown removed the whole scratch, including the TMPDIR of a child
|
|
2192
|
+
// still running or quarantined, whose descendants may still hold files there.
|
|
2193
|
+
test("advisories S2: session shutdown keeps a running or quarantined subagent's TMPDIR, removes the rest, and leaves the scratch to the sweep", async (t) => {
|
|
2194
|
+
const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "gentle-pi-advisories-s2-")));
|
|
2195
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
2196
|
+
const base = join(root, "tmp");
|
|
2197
|
+
fs.mkdirSync(base);
|
|
2198
|
+
const configHome = join(root, "config");
|
|
2199
|
+
const env: NodeJS.ProcessEnv = { TMPDIR: base, GENTLE_PI_CONFIG_HOME: configHome };
|
|
2200
|
+
const scratchSession = (sessionId: string, options: { isProcessAlive?: (pid: number) => boolean; now?: () => number } = {}) => {
|
|
2201
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
2202
|
+
createSessionScratchExtension({ env, ...options })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
|
|
2203
|
+
const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => sessionId } };
|
|
2204
|
+
return {
|
|
2205
|
+
start: () => handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx),
|
|
2206
|
+
shutdown: () => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx),
|
|
2207
|
+
};
|
|
2208
|
+
};
|
|
2209
|
+
const parent = scratchSession("s-1");
|
|
2210
|
+
parent.start();
|
|
2211
|
+
const scratch = env.TMPDIR!;
|
|
2212
|
+
assert.equal(dirname(scratch), base);
|
|
2213
|
+
const timers: Array<{ fn: () => void; ms: number }> = [];
|
|
2214
|
+
let now = 1_000;
|
|
2215
|
+
const fakes: FakeChild[] = [];
|
|
2216
|
+
const childTmps: string[] = [];
|
|
2217
|
+
const store = new TaskStore();
|
|
2218
|
+
const runner = new AgentRunner(store, { maxConcurrency: 2, stallTimeoutMs: 10_000 }, {
|
|
2219
|
+
spawn: (_command, _args, options) => {
|
|
2220
|
+
childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
|
|
2221
|
+
const fake = fakeChild({ exitOnKill: false, pid: 80 + fakes.length });
|
|
2222
|
+
fakes.push(fake);
|
|
2223
|
+
return fake.child;
|
|
2224
|
+
},
|
|
2225
|
+
now: () => now,
|
|
2226
|
+
schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
|
|
2227
|
+
pi: { command: "pi", args: [] },
|
|
2228
|
+
process: { platform: "win32", kill: () => {} },
|
|
2229
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
2230
|
+
const quarantined = runner.run(request({ env: { TMPDIR: scratch } }));
|
|
2231
|
+
const running = runner.run(request({ prompt: "running", env: { TMPDIR: scratch } }));
|
|
2232
|
+
await tick();
|
|
2233
|
+
runner.cancel(quarantined.id);
|
|
2234
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
2235
|
+
now = 5_000;
|
|
2236
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
2237
|
+
await tick();
|
|
2238
|
+
assert.match(store.get(quarantined.id)?.error ?? "", /capacity quarantined/);
|
|
2239
|
+
assert.deepEqual(childTmps.map((dir) => dirname(dir)), [scratch, scratch]);
|
|
2240
|
+
fs.writeFileSync(join(childTmps[0], "held.txt"), "x");
|
|
2241
|
+
fs.writeFileSync(join(scratch, "junk.txt"), "x");
|
|
2242
|
+
parent.shutdown();
|
|
2243
|
+
assert.ok(existsSync(join(childTmps[0], "held.txt")), "a quarantined child's TMPDIR stays");
|
|
2244
|
+
assert.ok(existsSync(childTmps[1]), "a running child's TMPDIR stays");
|
|
2245
|
+
assert.equal(existsSync(join(scratch, "junk.txt")), false, "the rest of the scratch is removed");
|
|
2246
|
+
assert.equal(env.TMPDIR, base, "the inherited TMPDIR is restored");
|
|
2247
|
+
const ledger = JSON.parse(readFileSync(join(configHome, "scratch", "s-1.json"), "utf8")) as { scratchDir: string | null };
|
|
2248
|
+
assert.equal(ledger.scratchDir, scratch, "the ledger keeps the scratch for a later sweep");
|
|
2249
|
+
fakes[0].exit(0);
|
|
2250
|
+
runner.cancel(running.id);
|
|
2251
|
+
fakes[1].exit(0);
|
|
2252
|
+
await tick();
|
|
2253
|
+
assert.ok(childTmps.every((dir) => !existsSync(dir)), "each proven exit removes its TMPDIR");
|
|
2254
|
+
const next = scratchSession("next", { isProcessAlive: () => false, now: () => Date.now() + 2 * 3_600_000 });
|
|
2255
|
+
next.start();
|
|
2256
|
+
assert.equal(existsSync(scratch), false, "the startup sweep removes the kept scratch once its owner ended");
|
|
2257
|
+
next.shutdown();
|
|
2258
|
+
});
|
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { once } from "node:events";
|
|
5
5
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
-
import { createConnection, createServer, Socket } from "node:net";
|
|
6
|
+
import { createConnection, createServer, Socket, type Server } from "node:net";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import nodeTest, { type TestContext } from "node:test";
|
|
@@ -124,7 +124,12 @@ test("lists only advertised peer IDs without mutating the private registry", asy
|
|
|
124
124
|
assert.equal(await snapshot(), before, "listing leaves presence files, directory modes, and socket entries unchanged");
|
|
125
125
|
assert.equal(alphaNew.createdAt >= alphaOld.createdAt, true);
|
|
126
126
|
for (let index = 0; index < 65; index++) await transport.publish(await transport.record(`overflow-${index}`, index));
|
|
127
|
-
|
|
127
|
+
// The over-cap scan sweeps provably dead records once instead of failing
|
|
128
|
+
// busy: the overflow endpoints were never bound, and the missing/malformed/
|
|
129
|
+
// symlinked fixtures are debris too. Live peers stay listed.
|
|
130
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "alpha", reachability: "unknown" }]);
|
|
131
|
+
const remaining = (await readdir(transport.paths.presence)).sort();
|
|
132
|
+
assert.equal(remaining.length, 3, "only the live records (self, alpha twice) survive the sweep");
|
|
128
133
|
});
|
|
129
134
|
|
|
130
135
|
test("keeps same-session activations distinct and old cleanup preserves the newest", async (t) => {
|
|
@@ -161,7 +166,52 @@ test("isolates two sessions and bounds registry discovery at 65 entries", async
|
|
|
161
166
|
assert.deepEqual(await transport.resolve("left"), left);
|
|
162
167
|
assert.deepEqual(await transport.resolve("right"), right);
|
|
163
168
|
for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index));
|
|
164
|
-
|
|
169
|
+
// The dead-endpoint overflow is swept on the first over-cap scan, so the
|
|
170
|
+
// retry resolves the truth (nothing left) instead of failing busy.
|
|
171
|
+
await rejected(transport.resolve("capped"), "not_found");
|
|
172
|
+
assert.equal((await readdir(transport.paths.presence)).length, 0, "dead overflow records are swept, never retained");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("foreign-schema presence files never count toward the activation cap", async (t) => {
|
|
176
|
+
const transport = await registry(t);
|
|
177
|
+
// The orchestrator publisher shares this directory with a different naming
|
|
178
|
+
// schema (<sessionHash>.<incarnation>.<kind>.json, three files per peer),
|
|
179
|
+
// and long-lived profiles accumulate hundreds of those records. They are
|
|
180
|
+
// never transport activations, so they must not trip the spam guard.
|
|
181
|
+
const incarnation = (index: number) => `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`;
|
|
182
|
+
for (let index = 0; index < 80; index++) {
|
|
183
|
+
for (const kind of ["header", "activity", "discovery"]) {
|
|
184
|
+
await writeFile(join(transport.paths.presence, `${"b".repeat(64)}.${incarnation(index)}.${kind}.json`), "{}");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const self = await raw(t, transport, "self", null);
|
|
188
|
+
const peer = await raw(t, transport, "peer", null);
|
|
189
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "listing survives a foreign-schema flood and still sees real activations");
|
|
190
|
+
assert.deepEqual(await transport.resolve("peer"), peer);
|
|
191
|
+
// The cap still bounds real transport activations exactly as before: the
|
|
192
|
+
// 65 advertised dead-endpoint records overflow the guard, the sweep removes
|
|
193
|
+
// them, and the live peers remain discoverable afterwards.
|
|
194
|
+
for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index + 10));
|
|
195
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "dead overflow is swept once and the live peer stays discoverable");
|
|
196
|
+
await rejected(transport.resolve("capped"), "not_found");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The sweep must never trade the spam guard for liveness probing: a genuine
|
|
200
|
+
// flood of records backed by real listening sockets stays over the cap, keeps
|
|
201
|
+
// failing busy, and its records are never removed.
|
|
202
|
+
test("a flood of live listeners stays over the cap and keeps failing busy", async (t) => {
|
|
203
|
+
const transport = await registry(t);
|
|
204
|
+
const servers: Server[] = [];
|
|
205
|
+
t.after(() => { for (const server of servers) server.close(); });
|
|
206
|
+
for (let index = 0; index < 66; index++) {
|
|
207
|
+
const record = await transport.record(`flood-${index}`, index);
|
|
208
|
+
const server = createServer(() => {});
|
|
209
|
+
await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, resolveListen); });
|
|
210
|
+
servers.push(server);
|
|
211
|
+
await transport.publish(record);
|
|
212
|
+
}
|
|
213
|
+
await rejected(transport.list(), "busy");
|
|
214
|
+
assert.equal((await readdir(transport.paths.presence)).length, 66, "live records are never swept");
|
|
165
215
|
});
|
|
166
216
|
|
|
167
217
|
test("rejects unsafe IDs, v1 schema violations, prototypes, and content-leaking errors", async (t) => {
|