@kontourai/flow-agents 2.3.0 → 3.0.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/.github/CODEOWNERS +16 -0
- package/.github/workflows/ci.yml +44 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +62 -0
- package/CONTEXT.md +35 -0
- package/README.md +9 -3
- package/agents/dev.json +1 -1
- package/agents/tool-code-reviewer.json +1 -1
- package/agents/tool-planner.json +3 -3
- package/agents/tool-verifier.json +3 -3
- package/build/src/cli/assignment-provider.d.ts +1 -0
- package/build/src/cli/assignment-provider.js +748 -0
- package/build/src/cli/console-learning-projection.js +3 -2
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/init.js +104 -21
- package/build/src/cli/kit.js +37 -6
- package/build/src/cli/pull-work-provider.js +1 -1
- package/build/src/cli/usage-feedback.js +3 -3
- package/build/src/cli/validate-hook-influence.js +1 -0
- package/build/src/cli/validate-workflow-artifacts.js +27 -7
- package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
- package/build/src/cli/workflow-sidecar.d.ts +14 -7
- package/build/src/cli/workflow-sidecar.js +938 -168
- package/build/src/cli.js +6 -0
- package/build/src/flow-kit/validate.d.ts +23 -0
- package/build/src/flow-kit/validate.js +67 -1
- package/build/src/index.d.ts +1 -1
- package/build/src/index.js +1 -1
- package/build/src/lib/flow-resolver.d.ts +6 -4
- package/build/src/lib/flow-resolver.js +125 -58
- package/build/src/lib/local-artifact-root.d.ts +14 -2
- package/build/src/lib/local-artifact-root.js +22 -5
- package/build/src/lib/workflow-learning-projection.js +2 -2
- package/build/src/runtime-adapters.d.ts +12 -0
- package/build/src/runtime-adapters.js +56 -16
- package/build/src/tools/build-universal-bundles.js +49 -16
- package/build/src/tools/generate-context-map.js +10 -8
- package/build/src/tools/validate-source-tree.js +24 -1
- package/console.telemetry.json +22 -0
- package/context/contracts/artifact-contract.md +40 -7
- package/context/contracts/assignment-provider-contract.md +239 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
- package/context/contracts/decision-registry-contract.md +265 -0
- package/context/contracts/delivery-contract.md +3 -1
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +4 -1
- package/context/contracts/review-contract.md +7 -3
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +11 -1
- package/context/contracts/work-item-contract.md +3 -1
- package/context/deferred/parallelization.md +1 -1
- package/context/gate-awareness.md +4 -4
- package/context/scripts/git-status.sh +2 -2
- package/context/scripts/hooks/config-protection.js +502 -2
- package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/context/scripts/hooks/run-hook.js +14 -1
- package/context/scripts/hooks/stop-goal-fit.js +1140 -99
- package/context/scripts/hooks/workflow-steering.js +212 -6
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
- package/docs/adr/0019-kit-dependency-ownership.md +62 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +463 -0
- package/docs/agent-system-guidebook.md +3 -3
- package/docs/agent-usage-feedback-loop.md +6 -6
- package/docs/context-map.md +10 -7
- package/docs/decisions/decision-records.md +57 -0
- package/docs/decisions/index.md +17 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/promotion-gate.md +52 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/developer-architecture.md +1 -1
- package/docs/fixture-ownership.md +4 -0
- package/docs/flow-kit-repository-contract.md +5 -5
- package/docs/getting-started.md +12 -2
- package/docs/integrations/harness-install.md +2 -1
- package/docs/kit-authoring-guide.md +64 -5
- package/docs/knowledge-kit.md +5 -1
- package/docs/learnings/2026-07-improvement-program.md +203 -0
- package/docs/migrations.md +6 -5
- package/docs/north-star.md +2 -2
- package/docs/operating-layers.md +4 -3
- package/docs/repository-structure.md +5 -5
- package/docs/skills-map.md +8 -8
- package/docs/spec/runtime-hook-surface.md +19 -15
- package/docs/spikes/graph-provider-2026-07.md +155 -0
- package/docs/standards-register.md +7 -7
- package/docs/trust-anchor-adoption.md +105 -4
- package/docs/work-item-adapters.md +1 -1
- package/docs/workflow-artifact-lifecycle.md +72 -9
- package/docs/workflow-eval-strategy.md +3 -3
- package/docs/workflow-shared-contracts.md +3 -2
- package/docs/workflow-usage-guide.md +260 -21
- package/evals/acceptance/DEMO-false-completion.md +20 -11
- package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
- package/evals/acceptance/prove-capture-teeth.sh +11 -11
- package/evals/acceptance/test_kiro_harness.sh +1 -1
- package/evals/ci/antigaming-suite.sh +5 -0
- package/evals/ci/run-baseline.sh +71 -1
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
- package/evals/fixtures/hook-influence/cases.json +32 -0
- package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
- package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
- package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
- package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
- package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
- package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
- package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
- package/evals/integration/test_actor_identity.sh +254 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_builder_step_producers.sh +33 -23
- package/evals/integration/test_bundle_install.sh +22 -16
- package/evals/integration/test_bundle_lifecycle.sh +11 -11
- package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
- package/evals/integration/test_checkpoint_signing.sh +43 -0
- package/evals/integration/test_claim_lookup.sh +4 -4
- package/evals/integration/test_codex_hook_resolution.sh +114 -0
- package/evals/integration/test_command_log_concurrency.sh +8 -8
- package/evals/integration/test_command_log_fork_classification.sh +3 -3
- package/evals/integration/test_command_log_integrity.sh +16 -16
- package/evals/integration/test_console_learning_projection.sh +1 -1
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_dual_emit_flow_step.sh +145 -0
- package/evals/integration/test_enforcer_expects_driven.sh +12 -12
- package/evals/integration/test_evidence_capture_hook.sh +14 -13
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_agents_statusline.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +52 -0
- package/evals/integration/test_flow_kit_repository.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +6 -6
- package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
- package/evals/integration/test_gate_bypass_chain.sh +52 -40
- package/evals/integration/test_gate_lockdown.sh +97 -73
- package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
- package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
- package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
- package/evals/integration/test_goal_fit_hook.sh +32 -32
- package/evals/integration/test_goal_fit_rederive.sh +9 -9
- package/evals/integration/test_hook_category_behaviors.sh +103 -0
- package/evals/integration/test_hook_influence_cases.sh +1 -0
- package/evals/integration/test_install_merge.sh +262 -1
- package/evals/integration/test_kit_identity_trust.sh +6 -6
- package/evals/integration/test_liveness_conflict_injection.sh +587 -0
- package/evals/integration/test_liveness_heartbeat.sh +677 -0
- package/evals/integration/test_liveness_verdict.sh +394 -0
- package/evals/integration/test_local_flow_kit_install.sh +15 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
- package/evals/integration/test_promote_gate.sh +178 -0
- package/evals/integration/test_publish_delivery.sh +23 -6
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
- package/evals/integration/test_pull_work_provider.sh +2 -2
- package/evals/integration/test_reconcile_soundness.sh +37 -13
- package/evals/integration/test_resolvefirststep_security.sh +22 -0
- package/evals/integration/test_runtime_adapter_activation.sh +87 -18
- package/evals/integration/test_session_resume_roundtrip.sh +147 -0
- package/evals/integration/test_sidecar_field_preservation.sh +249 -0
- package/evals/integration/test_trust_reconcile.sh +11 -10
- package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
- package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +759 -0
- package/evals/integration/test_usage_feedback_global.sh +5 -5
- package/evals/integration/test_validate_artifacts_portability.sh +60 -0
- package/evals/integration/test_verify_cli.sh +2 -2
- package/evals/integration/test_veritas_governance_kit.sh +117 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
- package/evals/integration/test_workflow_artifacts.sh +33 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +802 -74
- package/evals/integration/test_workflow_steering_hook.sh +175 -5
- package/evals/lib/codex-provider.sh +1 -1
- package/evals/lib/node.sh +19 -1
- package/evals/run.sh +32 -0
- package/evals/static/test_decisions.sh +150 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
- package/evals/static/test_knowledge_providers.sh +23 -0
- package/evals/static/test_package.sh +19 -10
- package/evals/static/test_universal_bundles.sh +48 -3
- package/evals/static/test_workflow_skills.sh +41 -8
- package/kits/builder/flows/build.flow.json +3 -74
- package/kits/builder/flows/publish-learn.flow.json +90 -0
- package/kits/builder/kit.json +11 -0
- package/kits/builder/skills/builder-shape/SKILL.md +1 -1
- package/kits/builder/skills/deliver/SKILL.md +32 -11
- package/kits/builder/skills/design-probe/SKILL.md +4 -4
- package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
- package/kits/builder/skills/execute-plan/SKILL.md +2 -2
- package/kits/builder/skills/fix-bug/SKILL.md +2 -0
- package/kits/builder/skills/gate-review/SKILL.md +6 -6
- package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
- package/kits/builder/skills/learning-review/SKILL.md +7 -7
- package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
- package/kits/builder/skills/plan-work/SKILL.md +27 -5
- package/kits/builder/skills/pull-work/SKILL.md +148 -5
- package/kits/builder/skills/release-readiness/SKILL.md +4 -4
- package/kits/builder/skills/review-work/SKILL.md +24 -1
- package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
- package/kits/builder/skills/verify-work/SKILL.md +8 -1
- package/kits/catalog.json +6 -0
- package/kits/knowledge/kit.json +35 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +125 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +24 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
- package/kits/veritas-governance/docs/README.md +75 -0
- package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
- package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
- package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
- package/kits/veritas-governance/kit.json +16 -0
- package/package.json +11 -5
- package/packaging/README.md +2 -2
- package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
- package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
- package/packaging/manifest.json +9 -10
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/decision-record.schema.json +109 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/schemas/workflow-evidence.schema.json +1 -1
- package/schemas/workflow-state.schema.json +5 -0
- package/scripts/README.md +8 -3
- package/scripts/check-content-boundary.cjs +1 -1
- package/scripts/check-decisions.cjs +356 -0
- package/scripts/ci/derive-claim-status.mjs +74 -0
- package/scripts/ci/trust-reconcile.js +957 -103
- package/scripts/git-status.sh +2 -2
- package/scripts/hooks/claude-telemetry-hook.js +37 -3
- package/scripts/hooks/codex-telemetry-hook.js +36 -2
- package/scripts/hooks/config-protection.js +102 -30
- package/scripts/hooks/evidence-capture.js +3 -3
- package/scripts/hooks/lib/actor-identity.js +368 -0
- package/scripts/hooks/lib/config-protection-remedies.js +69 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
- package/scripts/hooks/lib/liveness-policy.js +127 -0
- package/scripts/hooks/lib/liveness-read.js +105 -12
- package/scripts/hooks/lib/liveness-write.js +46 -0
- package/scripts/hooks/lib/local-artifact-paths.js +12 -6
- package/scripts/hooks/opencode-telemetry-hook.js +27 -1
- package/scripts/hooks/pi-telemetry-hook.js +27 -1
- package/scripts/hooks/stop-goal-fit.js +157 -26
- package/scripts/hooks/workflow-steering.js +77 -3
- package/scripts/install-codex-home.sh +138 -38
- package/scripts/statusline/flow-agents-statusline.js +2 -2
- package/src/cli/assignment-provider.ts +845 -0
- package/src/cli/console-learning-projection.ts +3 -2
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/init.ts +101 -21
- package/src/cli/kit.ts +37 -6
- package/src/cli/public-api.test.mjs +58 -6
- package/src/cli/pull-work-provider.ts +1 -1
- package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
- package/src/cli/usage-feedback.ts +3 -3
- package/src/cli/validate-hook-influence.ts +1 -0
- package/src/cli/validate-workflow-artifacts.ts +27 -7
- package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
- package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
- package/src/cli/workflow-sidecar.ts +933 -168
- package/src/cli.ts +6 -0
- package/src/flow-kit/validate.ts +74 -1
- package/src/index.ts +6 -2
- package/src/lib/flow-resolver.ts +123 -55
- package/src/lib/local-artifact-root.ts +24 -5
- package/src/lib/workflow-learning-projection.ts +2 -2
- package/src/runtime-adapters.ts +61 -15
- package/src/tools/build-universal-bundles.ts +49 -16
- package/src/tools/generate-context-map.ts +10 -8
- package/src/tools/validate-source-tree.ts +24 -1
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_liveness_heartbeat.sh — integration eval for the shared tool-activity
|
|
3
|
+
# liveness heartbeat (issue #288, Wave 3 Task 3.2 of the plan artifact at
|
|
4
|
+
# .kontourai/flow-agents/kontourai-flow-agents-288/kontourai-flow-agents-288--plan-work.md).
|
|
5
|
+
#
|
|
6
|
+
# Covers, per the plan and AC3/AC4/AC5/AC8:
|
|
7
|
+
# A. Policy semantics (scripts/hooks/lib/liveness-policy.js):
|
|
8
|
+
# default-on when unset/empty/arbitrary value; explicit opt-out for each
|
|
9
|
+
# of off|0|false|no|disabled (case-insensitive, whitespace-trimmed).
|
|
10
|
+
# B. maybeEmitHeartbeat({cwd, env, now}) throttle boundary, called directly
|
|
11
|
+
# via a stdin-piped `node -` script with an injected `now` for
|
|
12
|
+
# deterministic <60s throttled / >=60s emits proof (the plan's
|
|
13
|
+
# preferred, deterministic option (a)).
|
|
14
|
+
# C. maybeEmitHeartbeat skip reasons: no-current (no active_slug at all),
|
|
15
|
+
# no-claim (active_slug set but no prior claim event for the resolved
|
|
16
|
+
# actor), released (actor's last event is a release), disabled (explicit
|
|
17
|
+
# opt-out wins even with a fresh claim present), actor-unresolved (wins
|
|
18
|
+
# even with a fresh claim present for a different, resolvable actor).
|
|
19
|
+
# D. One end-to-end wrapper invocation (claude-telemetry-hook.js with a
|
|
20
|
+
# PostToolUse fixture) proving a heartbeat is appended for a fresh claim,
|
|
21
|
+
# plus a fail-open proof when the sidecar snapshot file is corrupted
|
|
22
|
+
# (malformed JSON).
|
|
23
|
+
# E. Telemetry-toggle independence (AC5): TELEMETRY_ENABLED=false still
|
|
24
|
+
# heartbeats — liveness heartbeats do not read/depend on that gate.
|
|
25
|
+
# F. Cross-runtime smoke: codex-telemetry-hook.js (distinct event-name path:
|
|
26
|
+
# raw eventType passed straight through, no canonicalEvent() mapping).
|
|
27
|
+
# G. Wrapper-level throttle smoke: two back-to-back real-clock invocations
|
|
28
|
+
# append exactly one heartbeat (the deterministic node stdin-script
|
|
29
|
+
# proof in B is the precise boundary test; this is the end-to-end
|
|
30
|
+
# companion the plan also asks for).
|
|
31
|
+
#
|
|
32
|
+
# Deterministic, no model spend, self-cleaning (mktemp -d + trap EXIT).
|
|
33
|
+
# Usage: bash evals/integration/test_liveness_heartbeat.sh
|
|
34
|
+
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
38
|
+
LIB_DIR="$ROOT/scripts/hooks/lib"
|
|
39
|
+
POLICY_MODULE="$LIB_DIR/liveness-policy.js"
|
|
40
|
+
HEARTBEAT_MODULE="$LIB_DIR/liveness-heartbeat.js"
|
|
41
|
+
READ_MODULE="$LIB_DIR/liveness-read.js"
|
|
42
|
+
ACTOR_MODULE="$LIB_DIR/actor-identity.js"
|
|
43
|
+
CLAUDE_HOOK="$ROOT/scripts/hooks/claude-telemetry-hook.js"
|
|
44
|
+
CODEX_HOOK="$ROOT/scripts/hooks/codex-telemetry-hook.js"
|
|
45
|
+
|
|
46
|
+
for m in "$POLICY_MODULE" "$HEARTBEAT_MODULE" "$READ_MODULE" "$ACTOR_MODULE" "$CLAUDE_HOOK" "$CODEX_HOOK"; do
|
|
47
|
+
if [[ ! -f "$m" ]]; then
|
|
48
|
+
echo "liveness heartbeat eval skipped: $m does not exist yet." >&2
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
TMPDIR_EVAL="$(mktemp -d)"
|
|
54
|
+
trap 'rm -rf "$TMPDIR_EVAL"' EXIT
|
|
55
|
+
|
|
56
|
+
errors=0
|
|
57
|
+
_pass() { echo " ✓ $1"; }
|
|
58
|
+
_fail() { echo " ✗ $1"; errors=$((errors + 1)); }
|
|
59
|
+
|
|
60
|
+
echo "=== Liveness heartbeat integration (#288) ==="
|
|
61
|
+
|
|
62
|
+
# ─── Fixture helpers ────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
SNAPSHOT_FILENAME="current.json"
|
|
65
|
+
|
|
66
|
+
# new_scratch — fresh scratch directory under the eval's own tmpdir.
|
|
67
|
+
new_scratch() {
|
|
68
|
+
mktemp -d "$TMPDIR_EVAL/scratch-XXXXXX"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# seed_claim <scratch_root> <slug> <actor> <at_iso> [ttl_seconds]
|
|
72
|
+
# Seeds <root>/.kontourai/flow-agents/<SNAPSHOT_FILENAME> (active_slug) and a
|
|
73
|
+
# single `claim` event in liveness/events.jsonl for <actor>, matching the
|
|
74
|
+
# exact shape workflow-sidecar.ts's liveness writer produces
|
|
75
|
+
# (subjectId/actor/at/ttlSeconds).
|
|
76
|
+
seed_claim() {
|
|
77
|
+
local root="$1" slug="$2" actor="$3" at="$4" ttl="${5:-1800}"
|
|
78
|
+
local artifact_root="$root/.kontourai/flow-agents"
|
|
79
|
+
mkdir -p "$artifact_root/liveness"
|
|
80
|
+
printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
|
|
81
|
+
printf '{"type":"claim","subjectId":"%s","actor":"%s","at":"%s","ttlSeconds":%s}\n' \
|
|
82
|
+
"$slug" "$actor" "$at" "$ttl" >"$artifact_root/liveness/events.jsonl"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# append_release <scratch_root> <slug> <actor> <at_iso>
|
|
86
|
+
append_release() {
|
|
87
|
+
local root="$1" slug="$2" actor="$3" at="$4"
|
|
88
|
+
local stream="$root/.kontourai/flow-agents/liveness/events.jsonl"
|
|
89
|
+
printf '{"type":"release","subjectId":"%s","actor":"%s","at":"%s"}\n' "$slug" "$actor" "$at" >>"$stream"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stream_file() {
|
|
93
|
+
printf '%s' "$1/.kontourai/flow-agents/liveness/events.jsonl"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stream_line_count() {
|
|
97
|
+
local f
|
|
98
|
+
f="$(stream_file "$1")"
|
|
99
|
+
[[ -f "$f" ]] && wc -l <"$f" | tr -d ' ' || echo 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# iso_offset_ms <delta_ms> — real-clock ISO timestamp `delta_ms` from now (used
|
|
103
|
+
# to seed a claim that is definitely past the heartbeat throttle window).
|
|
104
|
+
iso_offset_ms() {
|
|
105
|
+
DELTA_MS="$1" node - <<'NODE'
|
|
106
|
+
process.stdout.write(new Date(Date.now() + Number(process.env.DELTA_MS)).toISOString());
|
|
107
|
+
NODE
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# is_valid_json_stdin <string> — true if the given string parses as JSON.
|
|
111
|
+
is_valid_json() {
|
|
112
|
+
printf '%s' "$1" | node - <<'NODE'
|
|
113
|
+
let raw = '';
|
|
114
|
+
process.stdin.on('data', c => (raw += c));
|
|
115
|
+
process.stdin.on('end', () => {
|
|
116
|
+
try {
|
|
117
|
+
JSON.parse(raw);
|
|
118
|
+
process.exit(0);
|
|
119
|
+
} catch {
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
NODE
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# call_heartbeat <scratch_root> <env_json> [now_iso]
|
|
127
|
+
# Calls maybeEmitHeartbeat({cwd, env, now}) directly via a stdin-piped `node -`
|
|
128
|
+
# script (env passed through the process environment, not argv) and prints
|
|
129
|
+
# its JSON result (single line, stable key order per liveness-heartbeat.js).
|
|
130
|
+
call_heartbeat() {
|
|
131
|
+
local root="$1" env_json="$2" now="${3:-}"
|
|
132
|
+
MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" \
|
|
133
|
+
node - <<'NODE'
|
|
134
|
+
const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
|
|
135
|
+
const env = JSON.parse(process.env.ENV_JSON_ARG);
|
|
136
|
+
const opts = { cwd: process.env.ROOT_ARG, env };
|
|
137
|
+
if (process.env.NOW_ARG) opts.now = process.env.NOW_ARG;
|
|
138
|
+
process.stdout.write(JSON.stringify(maybeEmitHeartbeat(opts)));
|
|
139
|
+
NODE
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ─── A. Policy semantics (liveness-policy.js) ───────────────────────────────
|
|
143
|
+
echo "--- A. Policy semantics ---"
|
|
144
|
+
|
|
145
|
+
if MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
|
|
146
|
+
const { isLivenessEnabled } = require(process.env.MODULE_ARG);
|
|
147
|
+
if (isLivenessEnabled({}) !== true) throw new Error("isLivenessEnabled({}) (unset) must default to true");
|
|
148
|
+
if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "" }) !== true) throw new Error("isLivenessEnabled with empty string must default to true");
|
|
149
|
+
if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "on" }) !== true) throw new Error("isLivenessEnabled('on') must be enabled (any non-off-token enables)");
|
|
150
|
+
if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "1" }) !== true) throw new Error("isLivenessEnabled('1') must be enabled (any non-off-token enables)");
|
|
151
|
+
NODE
|
|
152
|
+
then
|
|
153
|
+
_pass "isLivenessEnabled defaults to true when unset/empty and for arbitrary non-off values (AC1)"
|
|
154
|
+
else
|
|
155
|
+
_fail "isLivenessEnabled did not default-on as expected"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
for token in off OFF Off " off " 0 false False FALSE no No NO disabled Disabled " disabled "; do
|
|
159
|
+
if MODULE_ARG="$POLICY_MODULE" TOKEN_ARG="$token" node - <<'NODE'
|
|
160
|
+
const { isLivenessEnabled } = require(process.env.MODULE_ARG);
|
|
161
|
+
const token = process.env.TOKEN_ARG;
|
|
162
|
+
const out = isLivenessEnabled({ FLOW_AGENTS_LIVENESS: token });
|
|
163
|
+
if (out !== false) throw new Error(`isLivenessEnabled did not disable for token ${JSON.stringify(token)}: got ${out}`);
|
|
164
|
+
NODE
|
|
165
|
+
then
|
|
166
|
+
_pass "isLivenessEnabled honors opt-out token $(printf '%q' "$token") (AC1)"
|
|
167
|
+
else
|
|
168
|
+
_fail "isLivenessEnabled did not honor opt-out token $(printf '%q' "$token")"
|
|
169
|
+
fi
|
|
170
|
+
done
|
|
171
|
+
|
|
172
|
+
# F4 (#288 fix iteration 1, sec-LOW): zero-width/format characters (U+200B/U+200C/U+200D/U+FEFF)
|
|
173
|
+
# must be stripped before the opt-out token compare — 'off<ZWSP>' must still disable. Codepoints
|
|
174
|
+
# are constructed entirely inside the node script (via String.fromCodePoint on a decimal
|
|
175
|
+
# codepoint passed through the env) to avoid ever embedding an invisible/BOM character literally
|
|
176
|
+
# in this shell script's own source text.
|
|
177
|
+
for zw_spec in "ZWSP:0x200B:before" "ZWJ:0x200D:middle" "ZWNJ:0x200C:middle" "BOM:0xFEFF:before"; do
|
|
178
|
+
zw_name="${zw_spec%%:*}"
|
|
179
|
+
zw_rest="${zw_spec#*:}"
|
|
180
|
+
zw_codepoint="${zw_rest%%:*}"
|
|
181
|
+
zw_position="${zw_rest#*:}"
|
|
182
|
+
if ZW_CODEPOINT_ARG="$zw_codepoint" ZW_POSITION_ARG="$zw_position" MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
|
|
183
|
+
const { isLivenessEnabled } = require(process.env.MODULE_ARG);
|
|
184
|
+
const zwChar = String.fromCodePoint(Number(process.env.ZW_CODEPOINT_ARG));
|
|
185
|
+
const token = process.env.ZW_POSITION_ARG === 'before' ? `${zwChar}off` : `o${zwChar}ff`;
|
|
186
|
+
const out = isLivenessEnabled({ FLOW_AGENTS_LIVENESS: token });
|
|
187
|
+
if (out !== false) throw new Error(`isLivenessEnabled did not disable for a zero-width-embedded token: got ${out}`);
|
|
188
|
+
NODE
|
|
189
|
+
then
|
|
190
|
+
_pass "isLivenessEnabled strips zero-width/format chars ($zw_name) before comparing the opt-out token (F4)"
|
|
191
|
+
else
|
|
192
|
+
_fail "isLivenessEnabled did not strip zero-width/format chars ($zw_name) before comparing the opt-out token"
|
|
193
|
+
fi
|
|
194
|
+
done
|
|
195
|
+
|
|
196
|
+
# F7 (#288 fix iteration 1, cr-LOW): parsePositiveIntOr uses a strict /^[0-9]+$/ literal match —
|
|
197
|
+
# non-integer-literal coercions Number() would otherwise accept (hex "0x10", exponential "1e3")
|
|
198
|
+
# must fall back to the default, not be silently accepted as 16 / 1000.
|
|
199
|
+
if MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
|
|
200
|
+
const { resolveTtlSeconds, resolveHeartbeatThrottleSeconds, DEFAULT_TTL_SECONDS, DEFAULT_HEARTBEAT_THROTTLE_SECONDS } = require(process.env.MODULE_ARG);
|
|
201
|
+
const cases = [
|
|
202
|
+
["FLOW_AGENTS_LIVENESS_TTL_SECONDS", "0x10", resolveTtlSeconds, DEFAULT_TTL_SECONDS],
|
|
203
|
+
["FLOW_AGENTS_LIVENESS_TTL_SECONDS", "1e3", resolveTtlSeconds, DEFAULT_TTL_SECONDS],
|
|
204
|
+
["FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS", "0x10", resolveHeartbeatThrottleSeconds, DEFAULT_HEARTBEAT_THROTTLE_SECONDS],
|
|
205
|
+
["FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS", "1e3", resolveHeartbeatThrottleSeconds, DEFAULT_HEARTBEAT_THROTTLE_SECONDS],
|
|
206
|
+
];
|
|
207
|
+
for (const [envVar, rawValue, resolver, expectedDefault] of cases) {
|
|
208
|
+
const got = resolver({ [envVar]: rawValue });
|
|
209
|
+
if (got !== expectedDefault) {
|
|
210
|
+
throw new Error(`${envVar}=${rawValue} should fall back to default ${expectedDefault}, got ${got}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// A genuine strict positive-integer literal must still parse correctly.
|
|
214
|
+
if (resolveTtlSeconds({ FLOW_AGENTS_LIVENESS_TTL_SECONDS: "300" }) !== 300) {
|
|
215
|
+
throw new Error("resolveTtlSeconds('300') should resolve to 300");
|
|
216
|
+
}
|
|
217
|
+
NODE
|
|
218
|
+
then
|
|
219
|
+
_pass "parsePositiveIntOr rejects hex (0x10) / exponential (1e3) coercions and falls back to defaults (F7)"
|
|
220
|
+
else
|
|
221
|
+
_fail "parsePositiveIntOr did not reject hex/exponential coercions as expected"
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# ─── B. maybeEmitHeartbeat throttle boundary (direct, injected `now`) ───────
|
|
225
|
+
echo "--- B. Throttle boundary (deterministic, injected now) ---"
|
|
226
|
+
|
|
227
|
+
B_ROOT="$(new_scratch)"
|
|
228
|
+
seed_claim "$B_ROOT" "b-subj" "agent-b" "2026-06-25T12:00:00.000Z" 1800
|
|
229
|
+
|
|
230
|
+
B_59S="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:00:59.000Z")"
|
|
231
|
+
if [[ "$B_59S" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 1 ]]; then
|
|
232
|
+
_pass "maybeEmitHeartbeat throttles at 59s since last event (< 60s, no append) (AC3)"
|
|
233
|
+
else
|
|
234
|
+
_fail "maybeEmitHeartbeat did not throttle at 59s: result=$B_59S lines=$(stream_line_count "$B_ROOT")"
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
B_60S="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:01:00.000Z")"
|
|
238
|
+
if [[ "$B_60S" == '{"emitted":true}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 2 ]]; then
|
|
239
|
+
_pass "maybeEmitHeartbeat emits at exactly 60s since last event (>= 60s boundary) (AC3)"
|
|
240
|
+
else
|
|
241
|
+
_fail "maybeEmitHeartbeat did not emit at the 60s boundary: result=$B_60S lines=$(stream_line_count "$B_ROOT")"
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
# Chained throttle: the newly-appended heartbeat is now the "last matching event"; an
|
|
245
|
+
# immediate follow-up call (0s later) must throttle against IT, not the original claim.
|
|
246
|
+
B_CHAIN="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:01:00.000Z")"
|
|
247
|
+
if [[ "$B_CHAIN" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 2 ]]; then
|
|
248
|
+
_pass "maybeEmitHeartbeat throttle re-anchors to the most recent heartbeat, not the original claim (AC3)"
|
|
249
|
+
else
|
|
250
|
+
_fail "maybeEmitHeartbeat throttle did not re-anchor to the most recent heartbeat: result=$B_CHAIN lines=$(stream_line_count "$B_ROOT")"
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
# F2 (#288 fix iteration 1, cr-HIGH coverage gap): FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS
|
|
254
|
+
# moves the throttle boundary to a custom value (5s here, not the 60s default) — proves the env
|
|
255
|
+
# var is actually consulted, not just the hard-coded default.
|
|
256
|
+
B2_ROOT="$(new_scratch)"
|
|
257
|
+
seed_claim "$B2_ROOT" "b2-subj" "agent-b2" "2026-06-25T12:00:00.000Z" 1800
|
|
258
|
+
|
|
259
|
+
B2_4S="$(call_heartbeat "$B2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2","FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS":"5"}' "2026-06-25T12:00:04.000Z")"
|
|
260
|
+
if [[ "$B2_4S" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B2_ROOT")" -eq 1 ]]; then
|
|
261
|
+
_pass "maybeEmitHeartbeat honors a custom FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS=5: throttles at 4s (F2, AC3)"
|
|
262
|
+
else
|
|
263
|
+
_fail "maybeEmitHeartbeat did not throttle at 4s under a custom 5s throttle: result=$B2_4S lines=$(stream_line_count "$B2_ROOT")"
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
B2_5S="$(call_heartbeat "$B2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2","FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS":"5"}' "2026-06-25T12:00:05.000Z")"
|
|
267
|
+
if [[ "$B2_5S" == '{"emitted":true}' ]] && [[ "$(stream_line_count "$B2_ROOT")" -eq 2 ]]; then
|
|
268
|
+
_pass "maybeEmitHeartbeat honors a custom FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS=5: emits at the 5s boundary, not the 60s default (F2, AC3)"
|
|
269
|
+
else
|
|
270
|
+
_fail "maybeEmitHeartbeat did not emit at the custom 5s boundary: result=$B2_5S lines=$(stream_line_count "$B2_ROOT")"
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
# The default (no env override) at the SAME 5s elapsed must still throttle (60s default) —
|
|
274
|
+
# proves the custom-value boundary shift in B2 is real, not a fixture artifact.
|
|
275
|
+
B2_DEFAULT_ROOT="$(new_scratch)"
|
|
276
|
+
seed_claim "$B2_DEFAULT_ROOT" "b2d-subj" "agent-b2d" "2026-06-25T12:00:00.000Z" 1800
|
|
277
|
+
B2_DEFAULT="$(call_heartbeat "$B2_DEFAULT_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2d"}' "2026-06-25T12:00:05.000Z")"
|
|
278
|
+
if [[ "$B2_DEFAULT" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B2_DEFAULT_ROOT")" -eq 1 ]]; then
|
|
279
|
+
_pass "maybeEmitHeartbeat default throttle (60s) still applies at 5s elapsed with no env override (F2 control case)"
|
|
280
|
+
else
|
|
281
|
+
_fail "maybeEmitHeartbeat default throttle did not hold at 5s elapsed: result=$B2_DEFAULT lines=$(stream_line_count "$B2_DEFAULT_ROOT")"
|
|
282
|
+
fi
|
|
283
|
+
|
|
284
|
+
# ─── C. Skip reasons: no-current / no-claim / released / disabled / actor-unresolved ──
|
|
285
|
+
echo "--- C. Skip reasons ---"
|
|
286
|
+
|
|
287
|
+
# C1: the sidecar snapshot is missing entirely -> no-current, no stream file created.
|
|
288
|
+
C1_ROOT="$(new_scratch)"
|
|
289
|
+
mkdir -p "$C1_ROOT/.kontourai/flow-agents"
|
|
290
|
+
C1_RESULT="$(call_heartbeat "$C1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c1"}')"
|
|
291
|
+
if [[ "$C1_RESULT" == '{"emitted":false,"reason":"no-current"}' ]] && [[ ! -f "$(stream_file "$C1_ROOT")" ]]; then
|
|
292
|
+
_pass "maybeEmitHeartbeat returns no-current when the sidecar snapshot / active_slug is absent (AC4)"
|
|
293
|
+
else
|
|
294
|
+
_fail "maybeEmitHeartbeat did not report no-current for a missing sidecar snapshot: result=$C1_RESULT"
|
|
295
|
+
fi
|
|
296
|
+
|
|
297
|
+
# C2: the sidecar snapshot names a slug with no prior events at all -> no-claim.
|
|
298
|
+
C2_ROOT="$(new_scratch)"
|
|
299
|
+
mkdir -p "$C2_ROOT/.kontourai/flow-agents"
|
|
300
|
+
printf '{"active_slug":"c2-subj"}\n' >"$C2_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
|
|
301
|
+
C2_RESULT="$(call_heartbeat "$C2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c2"}')"
|
|
302
|
+
if [[ "$C2_RESULT" == '{"emitted":false,"reason":"no-claim"}' ]]; then
|
|
303
|
+
_pass "maybeEmitHeartbeat returns no-claim when the named subject has no prior claim for the actor (AC4)"
|
|
304
|
+
else
|
|
305
|
+
_fail "maybeEmitHeartbeat did not report no-claim for an unclaimed subject: result=$C2_RESULT"
|
|
306
|
+
fi
|
|
307
|
+
|
|
308
|
+
# C3: a released claim (actor's last event is release) -> released, never re-claims.
|
|
309
|
+
C3_ROOT="$(new_scratch)"
|
|
310
|
+
seed_claim "$C3_ROOT" "c3-subj" "agent-c3" "2026-06-25T11:00:00.000Z" 1800
|
|
311
|
+
append_release "$C3_ROOT" "c3-subj" "agent-c3" "2026-06-25T11:05:00.000Z"
|
|
312
|
+
C3_RESULT="$(call_heartbeat "$C3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c3"}' "2026-06-25T11:10:00.000Z")"
|
|
313
|
+
if [[ "$C3_RESULT" == '{"emitted":false,"reason":"released"}' ]] && [[ "$(stream_line_count "$C3_ROOT")" -eq 2 ]]; then
|
|
314
|
+
_pass "maybeEmitHeartbeat returns released when the actor's last event for the subject is a release (AC4)"
|
|
315
|
+
else
|
|
316
|
+
_fail "maybeEmitHeartbeat did not report released after a release event: result=$C3_RESULT lines=$(stream_line_count "$C3_ROOT")"
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
# C4: explicit disabled wins even with a fresh, unreleased claim present.
|
|
320
|
+
C4_ROOT="$(new_scratch)"
|
|
321
|
+
seed_claim "$C4_ROOT" "c4-subj" "agent-c4" "2026-06-25T11:00:00.000Z" 1800
|
|
322
|
+
C4_RESULT="$(call_heartbeat "$C4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c4","FLOW_AGENTS_LIVENESS":"off"}' "2026-06-25T11:01:00.000Z")"
|
|
323
|
+
if [[ "$C4_RESULT" == '{"emitted":false,"reason":"disabled"}' ]] && [[ "$(stream_line_count "$C4_ROOT")" -eq 1 ]]; then
|
|
324
|
+
_pass "maybeEmitHeartbeat returns disabled and wins over a fresh claim (AC1, AC4)"
|
|
325
|
+
else
|
|
326
|
+
_fail "maybeEmitHeartbeat did not report disabled with a fresh claim present: result=$C4_RESULT lines=$(stream_line_count "$C4_ROOT")"
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
# C5: actor-unresolved wins even with a fresh claim present for a DIFFERENT, resolvable
|
|
330
|
+
# actor (proves the actor-unresolved check runs before the claim lookup, per the plan's
|
|
331
|
+
# step ordering, not merely "no claim for the unresolved actor").
|
|
332
|
+
C5_ROOT="$(new_scratch)"
|
|
333
|
+
seed_claim "$C5_ROOT" "c5-subj" "agent-c5-other" "2026-06-25T11:00:00.000Z" 1800
|
|
334
|
+
C5_RESULT="$(call_heartbeat "$C5_ROOT" '{"FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED":"1","NODE_ENV":"test"}' "2026-06-25T11:01:00.000Z")"
|
|
335
|
+
if [[ "$C5_RESULT" == '{"emitted":false,"reason":"actor-unresolved"}' ]] && [[ "$(stream_line_count "$C5_ROOT")" -eq 1 ]]; then
|
|
336
|
+
_pass "maybeEmitHeartbeat returns actor-unresolved and wins over a fresh claim held by another actor (AC4)"
|
|
337
|
+
else
|
|
338
|
+
_fail "maybeEmitHeartbeat did not report actor-unresolved: result=$C5_RESULT lines=$(stream_line_count "$C5_ROOT")"
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
# ─── D. End-to-end wrapper invocation (claude-telemetry-hook.js) ───────────
|
|
342
|
+
echo "--- D. End-to-end wrapper (claude-telemetry-hook.js) ---"
|
|
343
|
+
|
|
344
|
+
POST_TOOL_USE_PAYLOAD='{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"},"tool_response":{"stdout":"hi"}}'
|
|
345
|
+
|
|
346
|
+
# D1: a fresh (well past the 60s throttle, real clock) claim exists for the resolved
|
|
347
|
+
# actor -> exactly one heartbeat event is appended by the wrapper's postToolUse path.
|
|
348
|
+
D1_ROOT="$(new_scratch)"
|
|
349
|
+
D1_STALE_AT="$(iso_offset_ms -300000)"
|
|
350
|
+
seed_claim "$D1_ROOT" "d1-subj" "agent-hb" "$D1_STALE_AT" 1800
|
|
351
|
+
D1_OUT="$(cd "$D1_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
|
|
352
|
+
node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD")"
|
|
353
|
+
D1_STATUS=$?
|
|
354
|
+
if [[ "$D1_STATUS" -eq 0 ]] \
|
|
355
|
+
&& is_valid_json "$D1_OUT" \
|
|
356
|
+
&& [[ "$(stream_line_count "$D1_ROOT")" -eq 2 ]] \
|
|
357
|
+
&& grep -q '"type":"heartbeat"' "$(stream_file "$D1_ROOT")"; then
|
|
358
|
+
_pass "claude-telemetry-hook.js PostToolUse appends exactly one heartbeat for a fresh claim (AC3)"
|
|
359
|
+
else
|
|
360
|
+
_fail "claude-telemetry-hook.js PostToolUse did not append a heartbeat as expected: status=$D1_STATUS out=$D1_OUT lines=$(stream_line_count "$D1_ROOT")"
|
|
361
|
+
fi
|
|
362
|
+
|
|
363
|
+
# D2: a corrupted sidecar snapshot (malformed JSON) fails open — wrapper still exits 0
|
|
364
|
+
# with its normal stdout contract, and no heartbeat is fabricated from unparseable state.
|
|
365
|
+
D2_ROOT="$(new_scratch)"
|
|
366
|
+
mkdir -p "$D2_ROOT/.kontourai/flow-agents/liveness"
|
|
367
|
+
printf '{not valid json' >"$D2_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
|
|
368
|
+
D2_OUT="$(cd "$D2_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
|
|
369
|
+
node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" 2>"$TMPDIR_EVAL/d2.err")"
|
|
370
|
+
D2_STATUS=$?
|
|
371
|
+
if [[ "$D2_STATUS" -eq 0 ]] \
|
|
372
|
+
&& is_valid_json "$D2_OUT" \
|
|
373
|
+
&& [[ "$(stream_line_count "$D2_ROOT")" -eq 0 ]]; then
|
|
374
|
+
_pass "claude-telemetry-hook.js fails open on a corrupted sidecar snapshot (exit 0, no heartbeat fabricated) (AC3)"
|
|
375
|
+
else
|
|
376
|
+
_fail "claude-telemetry-hook.js did not fail open on a corrupted sidecar snapshot: status=$D2_STATUS out=$D2_OUT lines=$(stream_line_count "$D2_ROOT") stderr=$(cat "$TMPDIR_EVAL/d2.err")"
|
|
377
|
+
fi
|
|
378
|
+
|
|
379
|
+
# ─── E. Telemetry-toggle independence (AC5) ─────────────────────────────────
|
|
380
|
+
echo "--- E. Telemetry-toggle independence ---"
|
|
381
|
+
|
|
382
|
+
E_ROOT="$(new_scratch)"
|
|
383
|
+
E_STALE_AT="$(iso_offset_ms -300000)"
|
|
384
|
+
seed_claim "$E_ROOT" "e-subj" "agent-hb" "$E_STALE_AT" 1800
|
|
385
|
+
E_OUT="$(cd "$E_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
|
|
386
|
+
node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD")"
|
|
387
|
+
E_STATUS=$?
|
|
388
|
+
if [[ "$E_STATUS" -eq 0 ]] && [[ "$(stream_line_count "$E_ROOT")" -eq 2 ]] \
|
|
389
|
+
&& grep -q '"type":"heartbeat"' "$(stream_file "$E_ROOT")"; then
|
|
390
|
+
_pass "TELEMETRY_ENABLED=false still heartbeats — liveness is decoupled from the telemetry toggle (AC5)"
|
|
391
|
+
else
|
|
392
|
+
_fail "heartbeat did not fire with TELEMETRY_ENABLED=false: status=$E_STATUS out=$E_OUT lines=$(stream_line_count "$E_ROOT")"
|
|
393
|
+
fi
|
|
394
|
+
|
|
395
|
+
# ─── F. Cross-runtime smoke (codex-telemetry-hook.js) ───────────────────────
|
|
396
|
+
echo "--- F. Cross-runtime smoke (codex-telemetry-hook.js) ---"
|
|
397
|
+
|
|
398
|
+
CODEX_PAYLOAD='{"hook_event_name":"PostToolUse","tool_name":"shell","tool_input":{"command":"echo hi"}}'
|
|
399
|
+
F_ROOT="$(new_scratch)"
|
|
400
|
+
F_STALE_AT="$(iso_offset_ms -300000)"
|
|
401
|
+
seed_claim "$F_ROOT" "f-subj" "agent-hb" "$F_STALE_AT" 1800
|
|
402
|
+
F_OUT="$(cd "$F_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
|
|
403
|
+
node "$CODEX_HOOK" PostToolUse dev <<<"$CODEX_PAYLOAD")"
|
|
404
|
+
F_STATUS=$?
|
|
405
|
+
if [[ "$F_STATUS" -eq 0 ]] && [[ "$(stream_line_count "$F_ROOT")" -eq 2 ]] \
|
|
406
|
+
&& grep -q '"type":"heartbeat"' "$(stream_file "$F_ROOT")"; then
|
|
407
|
+
_pass "codex-telemetry-hook.js PostToolUse appends a heartbeat for a fresh claim (cross-runtime, AC3)"
|
|
408
|
+
else
|
|
409
|
+
_fail "codex-telemetry-hook.js PostToolUse did not append a heartbeat as expected: status=$F_STATUS out=$F_OUT lines=$(stream_line_count "$F_ROOT")"
|
|
410
|
+
fi
|
|
411
|
+
|
|
412
|
+
# ─── G. Wrapper-level throttle smoke (two back-to-back real-clock calls) ───
|
|
413
|
+
echo "--- G. Wrapper-level throttle smoke ---"
|
|
414
|
+
|
|
415
|
+
G_ROOT="$(new_scratch)"
|
|
416
|
+
G_STALE_AT="$(iso_offset_ms -300000)"
|
|
417
|
+
seed_claim "$G_ROOT" "g-subj" "agent-hb" "$G_STALE_AT" 1800
|
|
418
|
+
(cd "$G_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" >/dev/null)
|
|
419
|
+
(cd "$G_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" >/dev/null)
|
|
420
|
+
if [[ "$(stream_line_count "$G_ROOT")" -eq 2 ]]; then
|
|
421
|
+
_pass "two back-to-back wrapper invocations append exactly one heartbeat (real-clock throttle smoke, AC3)"
|
|
422
|
+
else
|
|
423
|
+
_fail "two back-to-back wrapper invocations did not throttle to one heartbeat: lines=$(stream_line_count "$G_ROOT") content=$(cat "$(stream_file "$G_ROOT")" 2>/dev/null)"
|
|
424
|
+
fi
|
|
425
|
+
|
|
426
|
+
# ─── H. Bounded tail read (F3, #288 fix iteration 1, sec-MED + cr-MED) ──────
|
|
427
|
+
echo "--- H. Bounded tail read (F3) ---"
|
|
428
|
+
|
|
429
|
+
# build_large_liveness_stream <root> <slug> <actor> <claim_at_iso> <include_recent_hb:true|false> [hb_at_iso]
|
|
430
|
+
# Seeds current.json's active_slug and a >64KB liveness/events.jsonl: ~900 unrelated filler
|
|
431
|
+
# lines (~85KB), then the target CLAIM event (now provably >64KB from EOF once the rest of the
|
|
432
|
+
# file is written), then ~900 more filler lines (~85KB), then optionally one target HEARTBEAT
|
|
433
|
+
# event as the very last line (provably within the last 64KB — it IS the last line). Margins are
|
|
434
|
+
# ~20KB+ on both sides of the 64KB boundary, comfortably clear of any off-by-one ambiguity.
|
|
435
|
+
build_large_liveness_stream() {
|
|
436
|
+
local root="$1" slug="$2" actor="$3" claim_at="$4" include_hb="${5:-false}" hb_at="${6:-}"
|
|
437
|
+
local artifact_root="$root/.kontourai/flow-agents"
|
|
438
|
+
mkdir -p "$artifact_root/liveness"
|
|
439
|
+
printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
|
|
440
|
+
SLUG_ARG="$slug" ACTOR_ARG="$actor" CLAIM_AT_ARG="$claim_at" INCLUDE_HB_ARG="$include_hb" \
|
|
441
|
+
HB_AT_ARG="$hb_at" STREAM_ARG="$artifact_root/liveness/events.jsonl" node - <<'NODE'
|
|
442
|
+
const fs = require('fs');
|
|
443
|
+
const filler = (i) =>
|
|
444
|
+
JSON.stringify({
|
|
445
|
+
type: 'heartbeat',
|
|
446
|
+
subjectId: `filler-subj-${i}`,
|
|
447
|
+
actor: `filler-actor-${i}`,
|
|
448
|
+
at: '2020-01-01T00:00:00.000Z',
|
|
449
|
+
});
|
|
450
|
+
const lines = [];
|
|
451
|
+
for (let i = 0; i < 900; i++) lines.push(filler(i));
|
|
452
|
+
lines.push(
|
|
453
|
+
JSON.stringify({
|
|
454
|
+
type: 'claim',
|
|
455
|
+
subjectId: process.env.SLUG_ARG,
|
|
456
|
+
actor: process.env.ACTOR_ARG,
|
|
457
|
+
at: process.env.CLAIM_AT_ARG,
|
|
458
|
+
ttlSeconds: 1800,
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
for (let i = 900; i < 1800; i++) lines.push(filler(i));
|
|
462
|
+
if (process.env.INCLUDE_HB_ARG === 'true') {
|
|
463
|
+
lines.push(
|
|
464
|
+
JSON.stringify({
|
|
465
|
+
type: 'heartbeat',
|
|
466
|
+
subjectId: process.env.SLUG_ARG,
|
|
467
|
+
actor: process.env.ACTOR_ARG,
|
|
468
|
+
at: process.env.HB_AT_ARG,
|
|
469
|
+
})
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
fs.writeFileSync(process.env.STREAM_ARG, lines.join('\n') + '\n');
|
|
473
|
+
NODE
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# call_heartbeat_instrumented <scratch_root> <env_json> [now_iso]
|
|
477
|
+
# Same as call_heartbeat, but monkey-patches fs.readFileSync to count calls against the
|
|
478
|
+
# liveness/events.jsonl stream specifically (readLivenessEvents, the FULL-read path) —
|
|
479
|
+
# readLivenessEventsTail uses fs.statSync/openSync/readSync/closeSync, never readFileSync, so
|
|
480
|
+
# any nonzero count here proves the full-read fallback executed. Prints
|
|
481
|
+
# {"result": <maybeEmitHeartbeat result>, "fullReadCalls": <n>}.
|
|
482
|
+
call_heartbeat_instrumented() {
|
|
483
|
+
local root="$1" env_json="$2" now="${3:-}"
|
|
484
|
+
MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" \
|
|
485
|
+
node - <<'NODE'
|
|
486
|
+
const fs = require('fs');
|
|
487
|
+
const path = require('path');
|
|
488
|
+
const originalReadFileSync = fs.readFileSync;
|
|
489
|
+
const streamSuffix = path.join('liveness', 'events.jsonl');
|
|
490
|
+
let fullReadCalls = 0;
|
|
491
|
+
fs.readFileSync = function (p, ...rest) {
|
|
492
|
+
if (typeof p === 'string' && p.endsWith(streamSuffix)) fullReadCalls += 1;
|
|
493
|
+
return originalReadFileSync.call(fs, p, ...rest);
|
|
494
|
+
};
|
|
495
|
+
const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
|
|
496
|
+
const env = JSON.parse(process.env.ENV_JSON_ARG);
|
|
497
|
+
const opts = { cwd: process.env.ROOT_ARG, env };
|
|
498
|
+
if (process.env.NOW_ARG) opts.now = process.env.NOW_ARG;
|
|
499
|
+
const result = maybeEmitHeartbeat(opts);
|
|
500
|
+
process.stdout.write(JSON.stringify({ result, fullReadCalls }));
|
|
501
|
+
NODE
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
# H1: claim is old (beyond the tail window) and a heartbeat for the SAME pair sits inside the
|
|
505
|
+
# tail, but that heartbeat is itself still within the throttle window of `now` -> the THROTTLE
|
|
506
|
+
# decision is bounded-tail-only (F8(ii): unchanged from F3) and refuses without ever needing to
|
|
507
|
+
# confirm claim evidence, so this must cost zero full reads.
|
|
508
|
+
H1_ROOT="$(new_scratch)"
|
|
509
|
+
build_large_liveness_stream "$H1_ROOT" "h1-subj" "agent-h1" "2026-06-25T09:00:00.000Z" true "2026-06-25T11:59:30.000Z"
|
|
510
|
+
H1_OUT="$(call_heartbeat_instrumented "$H1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h1"}' "2026-06-25T12:00:00.000Z")"
|
|
511
|
+
if [[ "$H1_OUT" == '{"result":{"emitted":false,"reason":"throttled"},"fullReadCalls":0}' ]]; then
|
|
512
|
+
_pass "maybeEmitHeartbeat throttle decision uses the bounded tail alone (no full read), even though the original claim has scrolled out of it (F3, F8(ii))"
|
|
513
|
+
else
|
|
514
|
+
_fail "maybeEmitHeartbeat did not take the bounded-only throttle path as expected: $H1_OUT"
|
|
515
|
+
fi
|
|
516
|
+
|
|
517
|
+
# H1B (F8(ii), #288 fix iteration 2): same shape as H1, but the in-tail heartbeat is old enough
|
|
518
|
+
# that the throttle window has elapsed — this is now an EMIT-time decision. The tail alone does
|
|
519
|
+
# NOT contain a `claim` event for the pair (only the heartbeat), so a bare heartbeat is no longer
|
|
520
|
+
# accepted as sufficient claim evidence on its own (that was the reviewer-reproduced orphan-
|
|
521
|
+
# heartbeat defect): exactly one full read must run to confirm the claim genuinely exists earlier
|
|
522
|
+
# in the true history, and only then does the heartbeat emit.
|
|
523
|
+
H1B_ROOT="$(new_scratch)"
|
|
524
|
+
build_large_liveness_stream "$H1B_ROOT" "h1b-subj" "agent-h1b" "2026-06-25T09:00:00.000Z" true "2026-06-25T11:55:00.000Z"
|
|
525
|
+
H1B_OUT="$(call_heartbeat_instrumented "$H1B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h1b"}' "2026-06-25T12:00:00.000Z")"
|
|
526
|
+
if [[ "$H1B_OUT" == '{"result":{"emitted":true},"fullReadCalls":1}' ]]; then
|
|
527
|
+
_pass "maybeEmitHeartbeat's EMIT decision pays exactly one full read to confirm claim evidence when only a heartbeat (no claim) is visible in the tail, and still emits once confirmed (F8(ii))"
|
|
528
|
+
else
|
|
529
|
+
_fail "maybeEmitHeartbeat did not fall back to a confirming full read for the claim-out-of-tail emit case: $H1B_OUT"
|
|
530
|
+
fi
|
|
531
|
+
|
|
532
|
+
# H2: the pair is entirely ABSENT from the tail (claim only, no subsequent heartbeat for this
|
|
533
|
+
# pair, and the claim itself has scrolled out of the last 64KB) -> the bounded read alone finds
|
|
534
|
+
# nothing, so exactly one full read must run as a fallback (fullReadCalls === 1), and the result
|
|
535
|
+
# must still be correct (the full read finds the old claim and emits, since it is well past the
|
|
536
|
+
# throttle window).
|
|
537
|
+
H2_ROOT="$(new_scratch)"
|
|
538
|
+
build_large_liveness_stream "$H2_ROOT" "h2-subj" "agent-h2" "2026-06-25T09:00:00.000Z" false
|
|
539
|
+
H2_OUT="$(call_heartbeat_instrumented "$H2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h2"}' "2026-06-25T12:00:00.000Z")"
|
|
540
|
+
if [[ "$H2_OUT" == '{"result":{"emitted":true},"fullReadCalls":1}' ]]; then
|
|
541
|
+
_pass "maybeEmitHeartbeat falls back to exactly one full read when the pair is entirely absent from the bounded tail, and still emits correctly (F3)"
|
|
542
|
+
else
|
|
543
|
+
_fail "maybeEmitHeartbeat did not fall back to a full read as expected: $H2_OUT"
|
|
544
|
+
fi
|
|
545
|
+
|
|
546
|
+
# H3 control: a SMALL stream (well under 64KB) behaves identically to before this change — the
|
|
547
|
+
# bounded tail read covers the whole file (including the claim event itself), so no fallback is
|
|
548
|
+
# ever needed (fullReadCalls === 0).
|
|
549
|
+
H3_ROOT="$(new_scratch)"
|
|
550
|
+
seed_claim "$H3_ROOT" "h3-subj" "agent-h3" "2026-06-25T09:00:00.000Z" 1800
|
|
551
|
+
H3_OUT="$(call_heartbeat_instrumented "$H3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h3"}' "2026-06-25T12:00:00.000Z")"
|
|
552
|
+
if [[ "$H3_OUT" == '{"result":{"emitted":true},"fullReadCalls":0}' ]]; then
|
|
553
|
+
_pass "maybeEmitHeartbeat never falls back to a full read for a small (< 64KB) stream (F3 regression control)"
|
|
554
|
+
else
|
|
555
|
+
_fail "maybeEmitHeartbeat behaved unexpectedly for a small stream: $H3_OUT"
|
|
556
|
+
fi
|
|
557
|
+
|
|
558
|
+
# build_large_liveness_stream_release <root> <slug> <actor> <claim_at_iso> <release_at_iso>
|
|
559
|
+
# Like build_large_liveness_stream, but seeds a claim FOLLOWED (many filler lines later) by a
|
|
560
|
+
# release for the same pair, with >64KB of filler both before the claim, between claim and
|
|
561
|
+
# release, and after the release — so the pair is entirely absent from the bounded tail and a
|
|
562
|
+
# full-read fallback is required to discover it at all.
|
|
563
|
+
build_large_liveness_stream_release() {
|
|
564
|
+
local root="$1" slug="$2" actor="$3" claim_at="$4" release_at="$5"
|
|
565
|
+
local artifact_root="$root/.kontourai/flow-agents"
|
|
566
|
+
mkdir -p "$artifact_root/liveness"
|
|
567
|
+
printf '{"active_slug":"%s"}
|
|
568
|
+
' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
|
|
569
|
+
SLUG_ARG="$slug" ACTOR_ARG="$actor" CLAIM_AT_ARG="$claim_at" RELEASE_AT_ARG="$release_at" STREAM_ARG="$artifact_root/liveness/events.jsonl" node - <<'NODE'
|
|
570
|
+
const fs = require('fs');
|
|
571
|
+
const filler = (i) =>
|
|
572
|
+
JSON.stringify({
|
|
573
|
+
type: 'heartbeat',
|
|
574
|
+
subjectId: `filler-subj-${i}`,
|
|
575
|
+
actor: `filler-actor-${i}`,
|
|
576
|
+
at: '2020-01-01T00:00:00.000Z',
|
|
577
|
+
});
|
|
578
|
+
const lines = [];
|
|
579
|
+
for (let i = 0; i < 900; i++) lines.push(filler(i));
|
|
580
|
+
lines.push(
|
|
581
|
+
JSON.stringify({
|
|
582
|
+
type: 'claim',
|
|
583
|
+
subjectId: process.env.SLUG_ARG,
|
|
584
|
+
actor: process.env.ACTOR_ARG,
|
|
585
|
+
at: process.env.CLAIM_AT_ARG,
|
|
586
|
+
ttlSeconds: 1800,
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
for (let i = 900; i < 1800; i++) lines.push(filler(i));
|
|
590
|
+
lines.push(
|
|
591
|
+
JSON.stringify({
|
|
592
|
+
type: 'release',
|
|
593
|
+
subjectId: process.env.SLUG_ARG,
|
|
594
|
+
actor: process.env.ACTOR_ARG,
|
|
595
|
+
at: process.env.RELEASE_AT_ARG,
|
|
596
|
+
})
|
|
597
|
+
);
|
|
598
|
+
for (let i = 1800; i < 2700; i++) lines.push(filler(i));
|
|
599
|
+
fs.writeFileSync(process.env.STREAM_ARG, lines.join('\n') + '\n');
|
|
600
|
+
NODE
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
# H4 (F8(ii), #288 fix iteration 2): release-most-recent still refuses even when BOTH the claim
|
|
604
|
+
# and the release have scrolled out of the bounded tail entirely, forcing the full-read fallback
|
|
605
|
+
# to be the one that discovers the pair's history — the fallback must still honor "release wins",
|
|
606
|
+
# not treat the earlier claim as license to emit.
|
|
607
|
+
H4_ROOT="$(new_scratch)"
|
|
608
|
+
build_large_liveness_stream_release "$H4_ROOT" "h4-subj" "agent-h4" "2026-06-25T09:00:00.000Z" "2026-06-25T10:00:00.000Z"
|
|
609
|
+
H4_OUT="$(call_heartbeat_instrumented "$H4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h4"}' "2026-06-25T12:00:00.000Z")"
|
|
610
|
+
if [[ "$H4_OUT" == '{"result":{"emitted":false,"reason":"released"},"fullReadCalls":1}' ]]; then
|
|
611
|
+
_pass "maybeEmitHeartbeat's full-read fallback still refuses on a most-recent release, even when both claim and release are absent from the bounded tail (F8(ii))"
|
|
612
|
+
else
|
|
613
|
+
_fail "maybeEmitHeartbeat did not honor a most-recent release discovered via the full-read fallback: $H4_OUT"
|
|
614
|
+
fi
|
|
615
|
+
|
|
616
|
+
# ─── I. Hostile active_slug never emits raw (F5, #288 fix iteration 1, sec-LOW) ─────
|
|
617
|
+
echo "--- I. Hostile active_slug sanitization (F5) ---"
|
|
618
|
+
|
|
619
|
+
I_ROOT="$(new_scratch)"
|
|
620
|
+
mkdir -p "$I_ROOT/.kontourai/flow-agents/liveness"
|
|
621
|
+
I_RAW_SLUG='evil;rm -rf /hb-subj'
|
|
622
|
+
I_SANITIZED_SLUG="$(ACTOR_MODULE_ARG="$ACTOR_MODULE" RAW_ARG="$I_RAW_SLUG" node - <<'NODE'
|
|
623
|
+
const { sanitizeSegment } = require(process.env.ACTOR_MODULE_ARG);
|
|
624
|
+
process.stdout.write(sanitizeSegment(process.env.RAW_ARG));
|
|
625
|
+
NODE
|
|
626
|
+
)"
|
|
627
|
+
printf '{"active_slug":"%s"}\n' "$I_RAW_SLUG" >"$I_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
|
|
628
|
+
printf '{"type":"claim","subjectId":"%s","actor":"agent-hb-i","at":"2026-06-25T11:00:00.000Z","ttlSeconds":1800}\n' \
|
|
629
|
+
"$I_SANITIZED_SLUG" >"$(stream_file "$I_ROOT")"
|
|
630
|
+
I_RESULT="$(call_heartbeat "$I_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-hb-i"}' "2026-06-25T11:05:00.000Z")"
|
|
631
|
+
I_STREAM_CONTENT="$(cat "$(stream_file "$I_ROOT")" 2>/dev/null)"
|
|
632
|
+
if [[ "$I_RESULT" == '{"emitted":true}' ]] \
|
|
633
|
+
&& echo "$I_STREAM_CONTENT" | grep -qF "\"subjectId\":\"$I_SANITIZED_SLUG\"" \
|
|
634
|
+
&& ! echo "$I_STREAM_CONTENT" | grep -qF ';rm -rf'; then
|
|
635
|
+
_pass "maybeEmitHeartbeat sanitizes a hostile active_slug (charset+cap, mirroring sanitizeSegment) before use — the emitted heartbeat's subjectId is never the raw hostile value (F5)"
|
|
636
|
+
else
|
|
637
|
+
_fail "maybeEmitHeartbeat did not sanitize a hostile active_slug as expected: result=$I_RESULT sanitized=$I_SANITIZED_SLUG stream=$I_STREAM_CONTENT"
|
|
638
|
+
fi
|
|
639
|
+
|
|
640
|
+
# ─── J. Orphan-heartbeat invariant (F8, #288 fix iteration 2) ──────────────
|
|
641
|
+
echo "--- J. Orphan-heartbeat invariant (F8) ---"
|
|
642
|
+
|
|
643
|
+
# seed_heartbeat_only <scratch_root> <slug> <actor> <at_iso>
|
|
644
|
+
# Seeds a liveness stream containing ONLY a heartbeat event for (slug, actor) — no claim was
|
|
645
|
+
# ever recorded. Reproduces the reviewer-found defect precondition: a direct CLI
|
|
646
|
+
# `liveness heartbeat` with no prior claim writes exactly this shape.
|
|
647
|
+
seed_heartbeat_only() {
|
|
648
|
+
local root="$1" slug="$2" actor="$3" at="$4"
|
|
649
|
+
local artifact_root="$root/.kontourai/flow-agents"
|
|
650
|
+
mkdir -p "$artifact_root/liveness"
|
|
651
|
+
printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
|
|
652
|
+
printf '{"type":"heartbeat","subjectId":"%s","actor":"%s","at":"%s"}\n' \
|
|
653
|
+
"$slug" "$actor" "$at" >"$artifact_root/liveness/events.jsonl"
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
# J1: an orphan heartbeat (no claim ever recorded for the pair), old enough to be past the
|
|
657
|
+
# throttle window, must resolve to no-claim — NOT be mistaken for its own claim evidence (the
|
|
658
|
+
# exact defect reproduced by the reviewer: "maybeEmitHeartbeat's bounded-tail path then treats it
|
|
659
|
+
# as claim evidence and perpetuates a phantom holder"). The tail alone has no `claim` event for
|
|
660
|
+
# the pair, so exactly one full read runs to confirm — and still finds none.
|
|
661
|
+
J1_ROOT="$(new_scratch)"
|
|
662
|
+
seed_heartbeat_only "$J1_ROOT" "j1-subj" "agent-j1" "2026-06-25T09:00:00.000Z"
|
|
663
|
+
J1_OUT="$(call_heartbeat_instrumented "$J1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-j1"}' "2026-06-25T12:00:00.000Z")"
|
|
664
|
+
if [[ "$J1_OUT" == '{"result":{"emitted":false,"reason":"no-claim"},"fullReadCalls":1}' ]]; then
|
|
665
|
+
_pass "maybeEmitHeartbeat treats a pre-existing orphan heartbeat (no claim ever recorded) as no-claim, neutralizing a phantom holder instead of perpetuating it (F8(ii))"
|
|
666
|
+
else
|
|
667
|
+
_fail "maybeEmitHeartbeat did not neutralize an orphan heartbeat as expected: $J1_OUT"
|
|
668
|
+
fi
|
|
669
|
+
|
|
670
|
+
echo ""
|
|
671
|
+
if [[ "$errors" -eq 0 ]]; then
|
|
672
|
+
echo "Liveness heartbeat integration passed."
|
|
673
|
+
exit 0
|
|
674
|
+
fi
|
|
675
|
+
|
|
676
|
+
echo "Liveness heartbeat integration failed: $errors issue(s)."
|
|
677
|
+
exit 1
|