gentle-pi 2.4.0 → 2.6.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -183,7 +183,6 @@ interface ParityRuntimeOptions {
|
|
|
183
183
|
pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
|
|
184
184
|
now?: () => number;
|
|
185
185
|
scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
|
|
186
|
-
writeConsentLatch?: (cwd: string) => void;
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime {
|
|
@@ -197,7 +196,7 @@ function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityR
|
|
|
197
196
|
now: options.now,
|
|
198
197
|
scheduleTimer: options.scheduleTimer,
|
|
199
198
|
} as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
200
|
-
__testing.createGentleAiExtension(dependencies
|
|
199
|
+
__testing.createGentleAiExtension(dependencies)({
|
|
201
200
|
on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
|
|
202
201
|
registerTool(definition: RegisteredControllerTool & { name: string }) { tools.set(definition.name, definition); },
|
|
203
202
|
registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
|
|
@@ -401,7 +400,7 @@ test("public gentle:review-mode handler reports current operations, global-off w
|
|
|
401
400
|
assert.deepEqual(unavailableNotices, [{ message: "Gentle AI review mode is not available with the currently negotiated native version.", type: "info" }]);
|
|
402
401
|
});
|
|
403
402
|
|
|
404
|
-
test("public consent relay
|
|
403
|
+
test("public consent relay allows any active session to resolve a live binding, is one-shot, and is candidate-scoped", async (t) => {
|
|
405
404
|
const cwd = repository(t);
|
|
406
405
|
const sharedRegistry = new PendingReviewConsentRegistry();
|
|
407
406
|
const fixture = consentNative(cwd);
|
|
@@ -411,14 +410,22 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
|
|
|
411
410
|
const sameSessionResult = await answerConsent(second, cwd, sameSession.consent_binding, "declined", "same-session");
|
|
412
411
|
assert.equal(sameSessionResult.outcome, "consent-declined-this-candidate");
|
|
413
412
|
|
|
413
|
+
// gentle-pi#455: a binding is answerable by whichever active Pi session
|
|
414
|
+
// presents it, not only the session whose START created it.
|
|
414
415
|
const blockedA = await beginConsent(first, cwd, "session-a");
|
|
415
416
|
assert.deepEqual(blockedA.consent, decodeReviewConsentV3(captured("consent-v3.captured.json")).raw);
|
|
416
|
-
const
|
|
417
|
-
assert.equal(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
417
|
+
const declinedFromOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
|
|
418
|
+
assert.equal(declinedFromOtherSession.operation, "answer-consent");
|
|
419
|
+
assert.equal(declinedFromOtherSession.outcome, "consent-declined-this-candidate");
|
|
420
|
+
assert.deepEqual(fixture.answers, ["declined", "declined"]);
|
|
421
|
+
|
|
422
|
+
// Cross-session resolution still enforces single use: once answered, no
|
|
423
|
+
// session -- including the one whose START created it -- may answer it again.
|
|
424
|
+
const staleAfterCrossSessionAnswer = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
|
|
425
|
+
assert.equal(staleAfterCrossSessionAnswer.operation, "answer-consent");
|
|
426
|
+
assertStaleConsentBinding(staleAfterCrossSessionAnswer, blockedA.consent_binding, "consent-binding-already-consumed");
|
|
427
|
+
assert.deepEqual(fixture.answers, ["declined", "declined"]);
|
|
428
|
+
|
|
422
429
|
const blockedB = await beginConsent(second, cwd, "session-b");
|
|
423
430
|
const declined = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
|
|
424
431
|
assert.equal(declined.outcome, "consent-declined-this-candidate");
|
|
@@ -426,15 +433,19 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
|
|
|
426
433
|
const staleConsumed = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
|
|
427
434
|
assert.equal(staleConsumed.operation, "answer-consent");
|
|
428
435
|
assertStaleConsentBinding(staleConsumed, blockedB.consent_binding, "consent-binding-already-consumed");
|
|
429
|
-
assert.deepEqual(fixture.answers, ["declined", "declined"]);
|
|
436
|
+
assert.deepEqual(fixture.answers, ["declined", "declined", "declined"]);
|
|
430
437
|
|
|
438
|
+
// A binding still pending when its owning session shuts down is discarded
|
|
439
|
+
// and becomes unreachable to every session -- cross-session resolution
|
|
440
|
+
// included.
|
|
441
|
+
const blockedShutdown = await beginConsent(first, cwd, "session-a");
|
|
431
442
|
const shutdown = first.events.get("session_shutdown");
|
|
432
443
|
assert.ok(shutdown);
|
|
433
444
|
await shutdown!({}, context(cwd, "session-a"));
|
|
434
445
|
await shutdown!({}, context(cwd, "session-a"));
|
|
435
|
-
const staleShutdown = await answerConsent(
|
|
446
|
+
const staleShutdown = await answerConsent(second, cwd, blockedShutdown.consent_binding, "declined", "session-b");
|
|
436
447
|
assert.equal(staleShutdown.operation, "answer-consent");
|
|
437
|
-
assertStaleConsentBinding(staleShutdown,
|
|
448
|
+
assertStaleConsentBinding(staleShutdown, blockedShutdown.consent_binding, "consent-binding-unknown");
|
|
438
449
|
|
|
439
450
|
writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
|
|
440
451
|
const nextCandidate = await beginConsent(second, cwd, "session-b");
|
|
@@ -450,7 +461,7 @@ test("public consent relay is session-bound, one-shot, and candidate-scoped", as
|
|
|
450
461
|
const staleModeCleared = await answerConsent(second, cwd, nextCandidate.consent_binding, "declined", "session-b");
|
|
451
462
|
assert.equal(staleModeCleared.operation, "answer-consent");
|
|
452
463
|
assertStaleConsentBinding(staleModeCleared, nextCandidate.consent_binding, "consent-binding-unknown");
|
|
453
|
-
assert.equal(fixture.starts.count,
|
|
464
|
+
assert.equal(fixture.starts.count, 5);
|
|
454
465
|
});
|
|
455
466
|
|
|
456
467
|
test("already-consumed local consent bindings reconcile exactly once with the native status transition", async (t) => {
|
|
@@ -500,13 +511,18 @@ test("launched answer-consent failures remain blocked after fresh-target STATUS
|
|
|
500
511
|
let answerCalls = 0;
|
|
501
512
|
fixture.native.answerConsent = async () => {
|
|
502
513
|
answerCalls += 1;
|
|
503
|
-
throw
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
514
|
+
throw {
|
|
515
|
+
name: "NativeReviewCliError",
|
|
516
|
+
code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
|
|
517
|
+
mutationOutcome: "unknown",
|
|
518
|
+
nextAction: "review.status",
|
|
519
|
+
diagnostics: {
|
|
520
|
+
operation: NATIVE_REVIEW_OPERATION.START,
|
|
521
|
+
error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
|
|
522
|
+
timed_out: false,
|
|
523
|
+
output_limit_exceeded: false,
|
|
524
|
+
},
|
|
525
|
+
};
|
|
510
526
|
};
|
|
511
527
|
const runtime = parityRuntime(fixture.native);
|
|
512
528
|
const blocked = await beginConsent(runtime, cwd);
|
|
@@ -522,6 +538,8 @@ test("launched answer-consent failures remain blocked after fresh-target STATUS
|
|
|
522
538
|
output_limit_exceeded: false,
|
|
523
539
|
});
|
|
524
540
|
assert.deepEqual(first.reconciliation, freshTargetStatus.raw);
|
|
541
|
+
assert.match(String(first.required_status_action), /Run target-scoped review\.status/);
|
|
542
|
+
assert.equal(first.next_action, "start");
|
|
525
543
|
assert.equal(statusRequests.length, 2, "the launched failure reconciles exactly once after the initial START status");
|
|
526
544
|
assert.equal(answerCalls, 1, "reconciliation must not replay answer-consent");
|
|
527
545
|
|
|
@@ -783,18 +801,14 @@ test("native START maps only provider facts and omits absent evidence", async (t
|
|
|
783
801
|
assert.equal(rendered.next_transition, nextTransition);
|
|
784
802
|
});
|
|
785
803
|
|
|
786
|
-
test("consent completion
|
|
804
|
+
test("candidate consent completion writes no persistent asked latch", async (t) => {
|
|
787
805
|
const cwd = repository(t);
|
|
788
806
|
const fixture = consentNative(cwd);
|
|
789
|
-
const
|
|
790
|
-
const runtime = parityRuntime(fixture.native, {
|
|
791
|
-
writeConsentLatch: () => { throw new Error("injected latch failure"); },
|
|
792
|
-
});
|
|
807
|
+
const runtime = parityRuntime(fixture.native);
|
|
793
808
|
const blocked = await beginConsent(runtime, cwd);
|
|
794
809
|
const completed = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
|
|
795
810
|
assert.equal((completed.result as { lineage_id?: string }).lineage_id, "consent-lineage");
|
|
796
811
|
assert.deepEqual(fixture.answers, ["granted"]);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// result remains authoritative even when the local best-effort latch fails.
|
|
812
|
+
const commonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8" }).trim();
|
|
813
|
+
assert.equal(existsSync(join(commonDir, "gentle-pi", "review-consent", "asked.json")), false);
|
|
800
814
|
});
|
|
@@ -147,7 +147,6 @@ test("workflow settle command line binds mandatory arguments and routing invaria
|
|
|
147
147
|
"--token <token>",
|
|
148
148
|
"--request-id <id>",
|
|
149
149
|
"--outcome <failed|interrupted|passed>",
|
|
150
|
-
"--evidence-revision <sha256:...>",
|
|
151
150
|
"--diagnosis <text>",
|
|
152
151
|
"--harness-disposition <reused|invalidated>",
|
|
153
152
|
"--cleanup-evidence <text>",
|
|
@@ -155,7 +154,13 @@ test("workflow settle command line binds mandatory arguments and routing invaria
|
|
|
155
154
|
]) {
|
|
156
155
|
assert.ok(settle.includes(arg), `settle command is missing ${arg}; command: ${settle}`);
|
|
157
156
|
}
|
|
158
|
-
assert.
|
|
157
|
+
assert.ok(settle.includes("[--evidence-revision <sha256:...>]"));
|
|
158
|
+
assert.match(section, /Every settle field except `evidence-revision` is required/);
|
|
159
|
+
assert.match(
|
|
160
|
+
section,
|
|
161
|
+
/For `failed` or `passed`, include `--evidence-revision` with the `sha256:\.\.\.` evidence hash\./,
|
|
162
|
+
);
|
|
163
|
+
assert.match(section, /For `interrupted`, omit the entire `--evidence-revision` flag\./);
|
|
159
164
|
assert.match(section, /proceed\|blocked\|complete/);
|
|
160
165
|
assert.match(section, /--successor-lineage/);
|
|
161
166
|
assert.match(section, /--remediates-evidence-revision/);
|
|
@@ -114,8 +114,21 @@ function measureOrchestratorPromptBytes(assetsDir: string): number {
|
|
|
114
114
|
// 2.2 — Byte budget (Spec: Always-On Injection Byte Budget)
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
|
|
117
|
+
// gentle-pi#661: `renderOrchestratorPrompt`/`getOrchestratorPrompt` default
|
|
118
|
+
// `rddStatusLine` to the "unknown (native status unavailable)" line -- the
|
|
119
|
+
// longest of the three renderable RDD status lines -- precisely so that a
|
|
120
|
+
// no-argument call renders the worst case this budget measures, not a
|
|
121
|
+
// smaller placeholder that production would later exceed. Assert that line
|
|
122
|
+
// is actually present so a future default change cannot silently start
|
|
123
|
+
// measuring a shorter render again.
|
|
124
|
+
const RDD_WORST_CASE_LINE = "Receipt-driven development: unknown (native status unavailable)";
|
|
125
|
+
|
|
117
126
|
test("getOrchestratorPrompt return value stays within the canonical 8,192 B budget at a short assets root", () => {
|
|
118
127
|
const rendered = __testing.renderOrchestratorPrompt(representativeProductionAssetsDir);
|
|
128
|
+
assert.ok(
|
|
129
|
+
rendered.includes(RDD_WORST_CASE_LINE),
|
|
130
|
+
"the default render must include the worst-case RDD status line to measure the real production budget",
|
|
131
|
+
);
|
|
119
132
|
const bytes = Buffer.byteLength(rendered, "utf8");
|
|
120
133
|
assert.ok(
|
|
121
134
|
bytes <= BUDGET_BYTES,
|
|
@@ -125,6 +138,10 @@ test("getOrchestratorPrompt return value stays within the canonical 8,192 B budg
|
|
|
125
138
|
|
|
126
139
|
test(`getOrchestratorPrompt keeps a controlled long (>= ${MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS} char) assets root within the canonical budget`, () => {
|
|
127
140
|
const rendered = __testing.renderOrchestratorPrompt(controlledLongAssetsDir);
|
|
141
|
+
assert.ok(
|
|
142
|
+
rendered.includes(RDD_WORST_CASE_LINE),
|
|
143
|
+
"the default render must include the worst-case RDD status line to measure the real production budget",
|
|
144
|
+
);
|
|
128
145
|
const bytes = measureOrchestratorPromptBytes(controlledLongAssetsDir);
|
|
129
146
|
assert.equal(bytes, Buffer.byteLength(rendered, "utf8"), "child-process and direct render byte counts must match");
|
|
130
147
|
assert.ok(
|
|
@@ -420,3 +437,55 @@ test("getOrchestratorPrompt memoizes the return across calls", () => {
|
|
|
420
437
|
const second = __testing.getOrchestratorPrompt();
|
|
421
438
|
assert.equal(second, first, "second call must return the memoized string");
|
|
422
439
|
});
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// gentle-pi#661 follow-up: byte-budget compression must not turn a pointer
|
|
443
|
+
// into an opaque "Detail: `file.md`" -- each pointer line must still name
|
|
444
|
+
// the lazy-loaded material it points to, in the shortest form that still
|
|
445
|
+
// says what is over there.
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
test("every compressed lazy-file pointer in the core still names the material it points to", () => {
|
|
449
|
+
const core = readFileSync(join(REAL_ASSETS_DIR, "orchestrator.md"), "utf8");
|
|
450
|
+
// One-sentence "<named material>: `file.md`." pointers, compressed for the
|
|
451
|
+
// byte budget -- each must keep naming what is over there, not collapse
|
|
452
|
+
// to an opaque "Detail: `file.md`.".
|
|
453
|
+
const namedPointers: ReadonlyArray<{ file: string; mustName: readonly string[] }> = [
|
|
454
|
+
{
|
|
455
|
+
file: "orchestrator-delegation.md",
|
|
456
|
+
mustName: ["Per-action table", "Work Routing Ladder", "Canonical Workflows", "blocking-prompt relays"],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
file: "orchestrator-memory.md",
|
|
460
|
+
mustName: ["Phase table", "artifact keys"],
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
file: "orchestrator-skills.md",
|
|
464
|
+
mustName: ["Discovery order", "intent hints"],
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
for (const { file, mustName } of namedPointers) {
|
|
468
|
+
// The pointer sentence: from the previous sentence boundary up to and
|
|
469
|
+
// including the backtick-quoted filename. `orchestrator-delegation.md`
|
|
470
|
+
// is referenced more than once in the core (a language-boundary pointer
|
|
471
|
+
// earlier, this compressed per-action pointer later) -- take the LAST
|
|
472
|
+
// occurrence, which is the one under test here.
|
|
473
|
+
const fileToken = `\`${file}\``;
|
|
474
|
+
const fileIndex = core.lastIndexOf(fileToken);
|
|
475
|
+
assert.ok(fileIndex >= 0, `core is missing a pointer to ${file}`);
|
|
476
|
+
const sentenceStart = core.lastIndexOf(".", fileIndex);
|
|
477
|
+
const pointerSentence = core.slice(sentenceStart + 1, fileIndex + fileToken.length);
|
|
478
|
+
for (const name of mustName) {
|
|
479
|
+
assert.ok(
|
|
480
|
+
pointerSentence.includes(name),
|
|
481
|
+
`the pointer to ${file} must name "${name}", got: ${JSON.stringify(pointerSentence.trim())}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// The SDD workflow pointer was never compressed to a bare filename; its
|
|
486
|
+
// surrounding paragraphs already name the material at length.
|
|
487
|
+
assert.match(
|
|
488
|
+
core,
|
|
489
|
+
/SDD phases, native dispatcher rules, status contract, preflight\/init guards, artifact-store policy, execution mode, Strict TDD forwarding, phase result contract, and review workload guard/,
|
|
490
|
+
);
|
|
491
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { historyDir, saveTask } from "../lib/agents-history.ts";
|
|
9
|
+
import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
10
|
+
import * as presence from "../lib/orchestrator-presence.ts";
|
|
11
|
+
import {
|
|
12
|
+
ACTIVITY_LIMIT, PresencePublisher, listPresence, projectActivity, readActivity,
|
|
13
|
+
} from "../lib/orchestrator-presence.ts";
|
|
14
|
+
|
|
15
|
+
function fixture(t: test.TestContext) {
|
|
16
|
+
const profile = fs.mkdtempSync(join(fs.realpathSync(tmpdir()), "presence-test-"));
|
|
17
|
+
// Each fixture owns precisely these files; never traverse a linked directory.
|
|
18
|
+
t.after(() => {
|
|
19
|
+
const root = join(profile, "gentle-agents");
|
|
20
|
+
if (fs.existsSync(root) && !fs.lstatSync(root).isSymbolicLink()) {
|
|
21
|
+
for (const name of fs.readdirSync(root)) {
|
|
22
|
+
const path = join(root, name);
|
|
23
|
+
if (["tasks", "presence"].includes(name) && fs.lstatSync(path).isDirectory()) {
|
|
24
|
+
for (const file of fs.readdirSync(path)) fs.unlinkSync(join(path, file));
|
|
25
|
+
fs.rmdirSync(path);
|
|
26
|
+
} else fs.unlinkSync(path);
|
|
27
|
+
}
|
|
28
|
+
fs.rmdirSync(root);
|
|
29
|
+
} else if (fs.existsSync(root)) fs.unlinkSync(root);
|
|
30
|
+
for (const name of fs.readdirSync(profile)) fs.unlinkSync(join(profile, name));
|
|
31
|
+
fs.rmdirSync(profile);
|
|
32
|
+
});
|
|
33
|
+
return profile;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rows(text = "full retained output") {
|
|
37
|
+
return [{ task: { id: "t", agent: "worker", label: "summary", status: "running", model: "model",
|
|
38
|
+
createdAt: 1000, startedAt: 1100, endedAt: null, lastActivityAt: 1200,
|
|
39
|
+
prompt: "PRIVATE_PROMPT", sessionPath: "/private/session", cwd: "/private/cwd", cancel: "CAPABILITY" },
|
|
40
|
+
thread: { version: 1, dropped: 2, items: [
|
|
41
|
+
{ kind: "text", text }, { kind: "thinking", text: "reasoning" }, { kind: "note", text: "note" },
|
|
42
|
+
{ kind: "tool", callId: "c", name: "read", output: "complete tool output", running: false,
|
|
43
|
+
isError: false, args: { prompt: "PRIVATE_PROMPT" }, sessionPath: "/private/session" },
|
|
44
|
+
] } }];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function realTask(): TaskRecord {
|
|
48
|
+
return { ...rows()[0].task, status: TASK_STATUS.RUNNING, mode: "task", parentSessionId: "same session",
|
|
49
|
+
thinking: undefined, error: null, result: null, lastStep: "read", turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function publisher(t: test.TestContext, profile: string, activity = rows()) {
|
|
53
|
+
const value = PresencePublisher.start({ profile, sessionId: "same session", label: "Workspace", activity });
|
|
54
|
+
t.after(() => value.dispose());
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function paths(profile: string, header: { sessionHash: string; incarnation: string }) {
|
|
59
|
+
const stem = `${header.sessionHash}.${header.incarnation}`;
|
|
60
|
+
return { header: join(profile, "gentle-agents", "presence", `${stem}.header.json`),
|
|
61
|
+
activity: join(profile, "gentle-agents", "presence", `${stem}.activity.json`) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function header(profile: string) {
|
|
65
|
+
const result = listPresence(profile);
|
|
66
|
+
assert.equal(result.unavailable, undefined);
|
|
67
|
+
assert.equal(result.entries.length, 1);
|
|
68
|
+
return result.entries[0];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.env.PRESENCE_TEST_CHILD !== "1") {
|
|
72
|
+
test("projection whitelists summaries and all retained text, excluding invocation payloads", () => {
|
|
73
|
+
const input = rows();
|
|
74
|
+
const projected = projectActivity(input);
|
|
75
|
+
const json = JSON.stringify(projected);
|
|
76
|
+
assert.doesNotMatch(json, /PRIVATE_PROMPT|private\/|CAPABILITY|sessionPath|cancel|args/);
|
|
77
|
+
assert.equal(projected.tasks[0].thread.items.length, 4);
|
|
78
|
+
assert.equal(projected.tasks[0].thread.dropped, 2);
|
|
79
|
+
input[0].thread.items[0].text = "mutated";
|
|
80
|
+
assert.equal(projected.tasks[0].thread.items[0].text, "full retained output");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("headers are private, lightweight, sanitized and recent at the inclusive TTL boundary", (t) => {
|
|
84
|
+
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"], now: 100_000 });
|
|
85
|
+
const profile = fixture(t);
|
|
86
|
+
const pub = PresencePublisher.start({ profile, sessionId: "/private/session", label: "\x1b[31mWork\nspace", activity: rows() });
|
|
87
|
+
t.after(() => pub.dispose());
|
|
88
|
+
const h = header(profile);
|
|
89
|
+
assert.equal(h.label, "Work space");
|
|
90
|
+
assert.equal(h.sessionHash, createHash("sha256").update("/private/session").digest("hex"));
|
|
91
|
+
assert.equal(h.counts.running, 1);
|
|
92
|
+
assert.doesNotMatch(fs.readFileSync(paths(profile, h).header, "utf8"), /private\/|full retained/);
|
|
93
|
+
assert.equal(listPresence(profile, 115_000).entries[0].recent, true);
|
|
94
|
+
assert.equal(listPresence(profile, 115_001).entries[0].recent, false);
|
|
95
|
+
assert.equal(listPresence(profile, 99_999).entries[0].recent, false);
|
|
96
|
+
if (process.platform !== "win32") {
|
|
97
|
+
assert.equal(fs.statSync(join(profile, "gentle-agents", "presence")).mode & 0o777, 0o700);
|
|
98
|
+
assert.equal(fs.statSync(paths(profile, h).header).mode & 0o777, 0o600);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("long display labels remain schema-valid when clipping lands on whitespace or Unicode", (t) => {
|
|
103
|
+
const profile = fixture(t);
|
|
104
|
+
const pub = PresencePublisher.start({ profile, sessionId: "s", label: `${"a".repeat(119)} tail`, activity: [] });
|
|
105
|
+
assert.equal(header(profile).label, "a".repeat(119));
|
|
106
|
+
pub.dispose();
|
|
107
|
+
const unicode = PresencePublisher.start({ profile, sessionId: "s", label: "🟢".repeat(121), activity: [] });
|
|
108
|
+
t.after(() => unicode.dispose());
|
|
109
|
+
assert.equal(header(profile).label, "🟢".repeat(120));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("updates coalesce, snapshot caller data, and heartbeat never serializes or replaces activity", (t) => {
|
|
113
|
+
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"], now: 100_000 });
|
|
114
|
+
const profile = fixture(t);
|
|
115
|
+
const pub = publisher(t, profile);
|
|
116
|
+
const first = header(profile);
|
|
117
|
+
const path = paths(profile, first).activity;
|
|
118
|
+
const original = fs.readFileSync(path, "utf8");
|
|
119
|
+
const inode = fs.statSync(path).ino;
|
|
120
|
+
pub.update(rows());
|
|
121
|
+
const stringify = JSON.stringify;
|
|
122
|
+
const serialization = t.mock.method(JSON, "stringify", (value: any) => {
|
|
123
|
+
assert.equal(value?.activity ?? value?.tasks, undefined, "heartbeat must not serialize activity");
|
|
124
|
+
return stringify(value);
|
|
125
|
+
});
|
|
126
|
+
t.mock.timers.tick(5000);
|
|
127
|
+
serialization.mock.restore();
|
|
128
|
+
assert.equal(header(profile).heartbeat, 105_000);
|
|
129
|
+
assert.equal(header(profile).generation, first.generation);
|
|
130
|
+
assert.equal(fs.statSync(path).ino, inode);
|
|
131
|
+
assert.equal(fs.readFileSync(path, "utf8"), original);
|
|
132
|
+
pub.update(rows("older"));
|
|
133
|
+
const latest = rows("latest");
|
|
134
|
+
pub.update(latest);
|
|
135
|
+
latest[0].thread.items[0].text = "caller mutation";
|
|
136
|
+
t.mock.timers.tick(399);
|
|
137
|
+
assert.equal(header(profile).generation, first.generation);
|
|
138
|
+
t.mock.timers.tick(1);
|
|
139
|
+
const next = header(profile);
|
|
140
|
+
assert.equal(next.generation, first.generation + 1);
|
|
141
|
+
assert.equal(readActivity(profile, next).activity?.tasks[0].thread.items[0].text, "latest");
|
|
142
|
+
assert.equal(readActivity(profile, first).unavailable, "generation-mismatch");
|
|
143
|
+
pub.update(rows("transient"));
|
|
144
|
+
pub.update(rows("latest"));
|
|
145
|
+
t.mock.timers.tick(400);
|
|
146
|
+
assert.equal(header(profile).generation, next.generation, "reverting a pending change publishes nothing");
|
|
147
|
+
pub.update(rows("discarded"));
|
|
148
|
+
pub.dispose();
|
|
149
|
+
t.mock.timers.tick(10_000);
|
|
150
|
+
assert.deepEqual(listPresence(profile).entries, []);
|
|
151
|
+
assert.throws(() => pub.update(rows()), /disposed/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("full under-limit payload is admitted; oversize is explicit and never truncated", (t) => {
|
|
155
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
|
156
|
+
const profile = fixture(t);
|
|
157
|
+
const text = "x".repeat(ACTIVITY_LIMIT - 2048);
|
|
158
|
+
const pub = publisher(t, profile, rows(text));
|
|
159
|
+
assert.equal(readActivity(profile, header(profile)).activity?.tasks[0].thread.items[0].text, text);
|
|
160
|
+
pub.update(rows("x".repeat(ACTIVITY_LIMIT)));
|
|
161
|
+
t.mock.timers.tick(400);
|
|
162
|
+
assert.equal(header(profile).unavailable, "activity-too-large");
|
|
163
|
+
assert.equal(readActivity(profile, header(profile)).unavailable, "activity-too-large");
|
|
164
|
+
pub.update(rows("recovered"));
|
|
165
|
+
t.mock.timers.tick(400);
|
|
166
|
+
assert.equal(readActivity(profile, header(profile)).activity?.tasks[0].thread.items[0].text, "recovered");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("reader rejects malformed, oversized, wrong digest/schema and unsafe exact targets", (t) => {
|
|
170
|
+
const profile = fixture(t);
|
|
171
|
+
publisher(t, profile);
|
|
172
|
+
const h = header(profile);
|
|
173
|
+
const p = paths(profile, h);
|
|
174
|
+
const original = fs.readFileSync(p.activity);
|
|
175
|
+
fs.writeFileSync(p.activity, "{}");
|
|
176
|
+
assert.equal(readActivity(profile, h).unavailable, "digest-mismatch");
|
|
177
|
+
const malformed = Buffer.from('{"schema":1,"generation":1,"activity":{"tasks":[{}]}}');
|
|
178
|
+
fs.writeFileSync(p.activity, malformed);
|
|
179
|
+
assert.equal(readActivity(profile, { ...h, digest: createHash("sha256").update(malformed).digest("hex") }).unavailable, "malformed");
|
|
180
|
+
fs.writeFileSync(p.activity, Buffer.alloc(ACTIVITY_LIMIT + 1));
|
|
181
|
+
assert.equal(readActivity(profile, h).unavailable, "oversized");
|
|
182
|
+
assert.equal(readActivity(profile, { ...h, incarnation: "../../escape" }).unavailable, "malformed");
|
|
183
|
+
fs.writeFileSync(p.activity, original);
|
|
184
|
+
fs.writeFileSync(p.header, "{");
|
|
185
|
+
assert.equal(listPresence(profile).rejected, 1);
|
|
186
|
+
fs.writeFileSync(p.header, " ".repeat(16 * 1024 + 1));
|
|
187
|
+
assert.equal(listPresence(profile).rejected, 1);
|
|
188
|
+
const { recent: _recent, ...storedHeader } = h;
|
|
189
|
+
fs.writeFileSync(p.header, JSON.stringify({ ...storedHeader, sessionPath: "/private" }));
|
|
190
|
+
assert.equal(listPresence(profile).rejected, 1);
|
|
191
|
+
fs.writeFileSync(p.header, JSON.stringify({ ...storedHeader, sessionHash: "0".repeat(64) }));
|
|
192
|
+
assert.equal(listPresence(profile).rejected, 1, "header identity must match its basename");
|
|
193
|
+
const invalid = JSON.parse(original.toString("utf8"));
|
|
194
|
+
invalid.activity.tasks[0].thread.items[0].sessionPath = "/private";
|
|
195
|
+
const injected = JSON.stringify(invalid);
|
|
196
|
+
fs.writeFileSync(p.activity, injected);
|
|
197
|
+
assert.equal(readActivity(profile, { ...h, digest: createHash("sha256").update(injected).digest("hex") }).unavailable, "malformed");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("bounded directory scan reports overflow and ignores partial/temp files", (t) => {
|
|
201
|
+
const profile = fixture(t);
|
|
202
|
+
publisher(t, profile);
|
|
203
|
+
const root = join(profile, "gentle-agents", "presence");
|
|
204
|
+
fs.writeFileSync(join(root, ".partial.tmp"), "{");
|
|
205
|
+
assert.equal(listPresence(profile).rejected, 0);
|
|
206
|
+
for (let i = 0; i < 130; i++) fs.writeFileSync(join(root, `junk-${i}`), "{}");
|
|
207
|
+
const result = listPresence(profile);
|
|
208
|
+
assert.equal(result.scanned, 128);
|
|
209
|
+
assert.equal(result.overflow, true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("symlinks, hardlinks, directories and non-private managed roots fail closed", { skip: process.platform === "win32" }, (t) => {
|
|
213
|
+
const profile = fixture(t);
|
|
214
|
+
const pub = publisher(t, profile);
|
|
215
|
+
const h = header(profile);
|
|
216
|
+
const p = paths(profile, h);
|
|
217
|
+
const outside = join(profile, "outside");
|
|
218
|
+
fs.writeFileSync(outside, "untouched");
|
|
219
|
+
fs.unlinkSync(p.activity);
|
|
220
|
+
fs.symlinkSync(outside, p.activity);
|
|
221
|
+
assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
|
|
222
|
+
fs.unlinkSync(p.activity);
|
|
223
|
+
fs.linkSync(outside, p.activity);
|
|
224
|
+
assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
|
|
225
|
+
pub.dispose();
|
|
226
|
+
assert.equal(fs.readFileSync(outside, "utf8"), "untouched");
|
|
227
|
+
assert.ok(fs.existsSync(p.activity), "cleanup refuses a replaced unsafe file");
|
|
228
|
+
fs.unlinkSync(p.activity);
|
|
229
|
+
fs.symlinkSync(outside, p.header);
|
|
230
|
+
assert.equal(listPresence(profile).rejected, 1);
|
|
231
|
+
fs.unlinkSync(p.header);
|
|
232
|
+
fs.linkSync(outside, p.header);
|
|
233
|
+
assert.equal(listPresence(profile).rejected, 1);
|
|
234
|
+
fs.unlinkSync(p.header);
|
|
235
|
+
fs.mkdirSync(p.activity);
|
|
236
|
+
assert.equal(readActivity(profile, h).unavailable, "unsafe-file");
|
|
237
|
+
fs.rmdirSync(p.activity);
|
|
238
|
+
fs.chmodSync(join(profile, "gentle-agents", "presence"), 0o755);
|
|
239
|
+
assert.equal(listPresence(profile).unavailable, "unsafe-directory");
|
|
240
|
+
fs.rmdirSync(join(profile, "gentle-agents", "presence"));
|
|
241
|
+
fs.symlinkSync(profile, join(profile, "gentle-agents", "presence"));
|
|
242
|
+
assert.equal(listPresence(profile).unavailable, "unsafe-directory");
|
|
243
|
+
assert.throws(() => publisher(t, profile), /unsafe-directory/);
|
|
244
|
+
fs.unlinkSync(join(profile, "gentle-agents", "presence"));
|
|
245
|
+
fs.chmodSync(join(profile, "gentle-agents"), 0o777);
|
|
246
|
+
assert.equal(listPresence(profile).unavailable, "unsafe-directory", "shared root cannot be writable by other users");
|
|
247
|
+
fs.chmodSync(join(profile, "gentle-agents"), 0o700);
|
|
248
|
+
fs.rmdirSync(join(profile, "gentle-agents"));
|
|
249
|
+
fs.symlinkSync(profile, join(profile, "gentle-agents"));
|
|
250
|
+
assert.throws(() => publisher(t, profile), /unsafe-directory/, "shared root cannot be a symlink");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("publication failure stops timers without overwriting linked files or another activation", { skip: process.platform === "win32" }, (t) => {
|
|
254
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
|
|
255
|
+
const profile = fixture(t);
|
|
256
|
+
const pub = publisher(t, profile);
|
|
257
|
+
const selected = header(profile);
|
|
258
|
+
const sibling = publisher(t, profile);
|
|
259
|
+
const target = paths(profile, selected).activity;
|
|
260
|
+
const outside = join(profile, "replacement");
|
|
261
|
+
fs.writeFileSync(outside, "untouched");
|
|
262
|
+
fs.unlinkSync(target);
|
|
263
|
+
fs.linkSync(outside, target);
|
|
264
|
+
pub.update(rows("must not publish"));
|
|
265
|
+
t.mock.timers.tick(400);
|
|
266
|
+
assert.equal(pub.error, "unsafe-file");
|
|
267
|
+
t.mock.timers.tick(10_000);
|
|
268
|
+
assert.equal(fs.readFileSync(outside, "utf8"), "untouched");
|
|
269
|
+
assert.deepEqual(listPresence(profile).entries.map((h) => h.incarnation), [sibling.target.incarnation]);
|
|
270
|
+
assert.equal(readActivity(profile, selected).unavailable, "unsafe-file");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("history created under umask 022 coexists with a dedicated private presence namespace", async (t) => {
|
|
274
|
+
const profile = fixture(t);
|
|
275
|
+
const store = new TaskStore();
|
|
276
|
+
const task = realTask();
|
|
277
|
+
store.add(task);
|
|
278
|
+
const mask = process.umask(0o022);
|
|
279
|
+
try { await saveTask(historyDir(profile, profile), task, store.thread(task.id)); }
|
|
280
|
+
finally { process.umask(mask); }
|
|
281
|
+
const shared = join(profile, "gentle-agents");
|
|
282
|
+
const before = fs.statSync(shared).mode;
|
|
283
|
+
if (process.platform !== "win32") assert.equal(before & 0o777, 0o755);
|
|
284
|
+
const pub = publisher(t, profile);
|
|
285
|
+
assert.equal(fs.statSync(shared).mode, before, "must not chmod the shared history directory");
|
|
286
|
+
const privateRoot = join(shared, "presence");
|
|
287
|
+
assert.ok(fs.statSync(privateRoot).isDirectory());
|
|
288
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(privateRoot).mode & 0o777, 0o700);
|
|
289
|
+
pub.dispose();
|
|
290
|
+
assert.ok(fs.existsSync(join(historyDir(profile, profile), `${task.id}.json`)));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("real TaskStore projection preserves remote sorting/retention timestamps and header identity", (t) => {
|
|
294
|
+
const profile = fixture(t);
|
|
295
|
+
const store = new TaskStore();
|
|
296
|
+
const task = realTask();
|
|
297
|
+
store.add(task);
|
|
298
|
+
store.apply(task.id, { type: "text", text: "retained" }, 2000);
|
|
299
|
+
store.update(task.id, { status: TASK_STATUS.COMPLETED, endedAt: 2500 });
|
|
300
|
+
const activity = store.list(task.parentSessionId).map((task) => ({ task, thread: store.thread(task.id) }));
|
|
301
|
+
const projected = projectActivity(activity);
|
|
302
|
+
assert.throws(() => projectActivity([{ ...activity[0], task: { ...activity[0].task, endedAt: NaN } }]), /malformed-activity/);
|
|
303
|
+
assert.deepEqual(projected.tasks[0].summary, { id: "t", agent: "worker", label: "summary", model: "model",
|
|
304
|
+
status: TASK_STATUS.COMPLETED, createdAt: 1000, startedAt: 1100, endedAt: 2500, lastActivityAt: 2000 });
|
|
305
|
+
const pub = PresencePublisher.start({ profile, sessionId: task.parentSessionId, label: "Work", activity });
|
|
306
|
+
t.after(() => pub.dispose());
|
|
307
|
+
const h = header(profile);
|
|
308
|
+
assert.equal(h.sessionHash, createHash("sha256").update(task.parentSessionId).digest("hex"));
|
|
309
|
+
assert.deepEqual(readActivity(profile, h).activity, projected);
|
|
310
|
+
assert.doesNotMatch(JSON.stringify(projected), /parentSessionId|PRIVATE_PROMPT|sessionPath|cwd|cancel|args/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("bounded continuation reaches a live peer beyond 128 stale headers without deleting them", (t) => {
|
|
314
|
+
const profile = fixture(t);
|
|
315
|
+
const pub = publisher(t, profile);
|
|
316
|
+
const { recent: _recent, ...base } = header(profile);
|
|
317
|
+
pub.dispose();
|
|
318
|
+
const root = join(profile, "gentle-agents", "presence");
|
|
319
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
320
|
+
for (let i = 0; i < 140; i++) {
|
|
321
|
+
const h = { ...base, incarnation: randomUUID(), heartbeat: 0 };
|
|
322
|
+
fs.writeFileSync(join(root, `${h.sessionHash}.${h.incarnation}.header.json`), JSON.stringify(h));
|
|
323
|
+
}
|
|
324
|
+
// Choose the late peer from actual filesystem order, not lexical/insertion assumptions.
|
|
325
|
+
const fixtureDir = fs.opendirSync(root);
|
|
326
|
+
let late = "";
|
|
327
|
+
try {
|
|
328
|
+
for (let i = 0; i < 141; i++) {
|
|
329
|
+
const entry = fixtureDir.readSync();
|
|
330
|
+
if (!entry) break;
|
|
331
|
+
late = entry.name;
|
|
332
|
+
}
|
|
333
|
+
} finally { fixtureDir.closeSync(); }
|
|
334
|
+
const live = { ...JSON.parse(fs.readFileSync(join(root, late), "utf8")), heartbeat: Date.now() };
|
|
335
|
+
fs.writeFileSync(join(root, late), JSON.stringify(live));
|
|
336
|
+
const cursor = new presence.PresenceCursor(profile);
|
|
337
|
+
t.after(() => cursor.close());
|
|
338
|
+
const first = cursor.next();
|
|
339
|
+
assert.equal(first.scanned, 128);
|
|
340
|
+
assert.equal(first.overflow, true);
|
|
341
|
+
assert.equal(first.entries.some((h) => h.recent), false);
|
|
342
|
+
const second = cursor.next();
|
|
343
|
+
assert.equal(second.scanned, 12);
|
|
344
|
+
assert.equal(second.overflow, false);
|
|
345
|
+
assert.deepEqual(second.entries.filter((h) => h.recent).map((h) => h.incarnation), [live.incarnation]);
|
|
346
|
+
assert.equal(cursor.next().unavailable, "closed");
|
|
347
|
+
assert.equal(fs.readdirSync(root).length, 140, "stale entries are not deleted");
|
|
348
|
+
const abandoned = new presence.PresenceCursor(profile);
|
|
349
|
+
abandoned.close();
|
|
350
|
+
abandoned.close();
|
|
351
|
+
assert.equal(abandoned.next().unavailable, "closed");
|
|
352
|
+
if (process.platform !== "win32") {
|
|
353
|
+
const unsafe = new presence.PresenceCursor(profile);
|
|
354
|
+
fs.chmodSync(root, 0o755);
|
|
355
|
+
assert.equal(unsafe.next().unavailable, "unsafe-directory");
|
|
356
|
+
assert.equal(unsafe.next().unavailable, "closed", "a failed cursor closes its handle");
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Execute this same module in isolated Node processes, without an extra fixture file.
|
|
362
|
+
if (process.env.PRESENCE_TEST_CHILD === "1") {
|
|
363
|
+
const pub = PresencePublisher.start({ profile: process.argv[2], sessionId: "shared", label: "Child", activity: rows() });
|
|
364
|
+
process.send?.(pub.target);
|
|
365
|
+
process.on("message", () => { pub.dispose(); process.exit(0); });
|
|
366
|
+
} else {
|
|
367
|
+
test("two real processes with the same session ID stay distinct and clean up only their incarnation", async (t) => {
|
|
368
|
+
const profile = fixture(t);
|
|
369
|
+
const children = [0, 1].map(() => fork(new URL(import.meta.url), [profile], {
|
|
370
|
+
execArgv: ["--experimental-strip-types"], env: { PRESENCE_TEST_CHILD: "1" }, silent: true,
|
|
371
|
+
}));
|
|
372
|
+
t.after(() => { for (const child of children) if (child.exitCode === null) child.kill(); });
|
|
373
|
+
const targets = await Promise.all(children.map((child) => new Promise<any>((resolve, reject) => {
|
|
374
|
+
child.once("message", resolve);
|
|
375
|
+
child.once("error", reject);
|
|
376
|
+
child.once("exit", (code) => reject(new Error(`child exited before readiness: ${code}`)));
|
|
377
|
+
})));
|
|
378
|
+
assert.equal(targets[0].sessionHash, targets[1].sessionHash);
|
|
379
|
+
assert.notEqual(targets[0].incarnation, targets[1].incarnation);
|
|
380
|
+
assert.equal(listPresence(profile).entries.length, 2);
|
|
381
|
+
const survivor = listPresence(profile).entries.find((h) => h.incarnation === targets[1].incarnation)!;
|
|
382
|
+
assert.ok(readActivity(profile, survivor).activity);
|
|
383
|
+
await new Promise<void>((resolve) => { children[0].once("exit", () => resolve()); children[0].send("dispose"); });
|
|
384
|
+
assert.deepEqual(listPresence(profile).entries.map((h) => h.incarnation), [survivor.incarnation]);
|
|
385
|
+
assert.ok(readActivity(profile, survivor).activity);
|
|
386
|
+
await new Promise<void>((resolve) => { children[1].once("exit", () => resolve()); children[1].send("dispose"); });
|
|
387
|
+
assert.deepEqual(fs.readdirSync(join(profile, "gentle-agents", "presence")), []);
|
|
388
|
+
});
|
|
389
|
+
}
|