@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,361 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* liveness-heartbeat.js — shared pure-CJS tool-activity liveness heartbeat
|
|
4
|
+
*
|
|
5
|
+
* Zero external dependencies (only Node core: fs, path). Consumed by:
|
|
6
|
+
* - scripts/hooks/{claude,codex,opencode,pi}-telemetry-hook.js (CJS, direct require)
|
|
7
|
+
*
|
|
8
|
+
* Purpose (issue #288): ride the one hook event every supported runtime
|
|
9
|
+
* already fires per tool call (postToolUse) to keep a liveness claim fresh
|
|
10
|
+
* via ordinary tool activity, not just workflow phase transitions. Throttled
|
|
11
|
+
* to >=`resolveHeartbeatThrottleSeconds(env)` (default 60s) per subject+actor,
|
|
12
|
+
* derived from the existing actor-keyed `liveness/events.jsonl` stream tail —
|
|
13
|
+
* deliberately NOT a new mutable throttle-state file, since a shared
|
|
14
|
+
* last-heartbeat-timestamp file keyed by anything less specific than the
|
|
15
|
+
* actor would reintroduce the last-writer-wins race ADR 0021 exists to close
|
|
16
|
+
* (see plan artifact for kontourai-flow-agents-288). Each actor only ever
|
|
17
|
+
* contends with its own prior writes, so deriving the throttle straight from
|
|
18
|
+
* the actor-scoped stream has no shared-write race.
|
|
19
|
+
*
|
|
20
|
+
* Hot-path ordering + bounded read (F3, #288 fix iteration 1, sec-MED +
|
|
21
|
+
* cr-MED): steps run enabled? -> current.json active_slug present? ->
|
|
22
|
+
* resolveActor() -> bounded tail read, in that order, so a repo with liveness
|
|
23
|
+
* disabled or no active session never pays the `resolveActor()` process-
|
|
24
|
+
* ancestry `ps` spawn cost.
|
|
25
|
+
*
|
|
26
|
+
* Orphan-heartbeat invariant (F8(ii), #288 fix iteration 2): a heartbeat must
|
|
27
|
+
* never be treated as its own evidence of a claim. The reviewer-reproduced
|
|
28
|
+
* defect was that a bare heartbeat sitting in the bounded tail (with no claim
|
|
29
|
+
* event anywhere nearby) was accepted as sufficient "claim evidence" to keep
|
|
30
|
+
* emitting further heartbeats — perpetuating a phantom holder that was never
|
|
31
|
+
* legitimately claimed. The fix splits the two decisions this module makes:
|
|
32
|
+
*
|
|
33
|
+
* 1. THROTTLE decision — stays bounded-tail only (cheap, unchanged). The
|
|
34
|
+
* tail is a suffix of an append-only stream, so when it contains ANY
|
|
35
|
+
* event for the (subjectId, actor) pair, that event is necessarily the
|
|
36
|
+
* pair's true most-recent entry (nothing for this pair could exist
|
|
37
|
+
* later in the file without also being in the tail). A release found
|
|
38
|
+
* there refuses immediately; a fresh (within-throttle) event there
|
|
39
|
+
* throttles immediately. Neither outcome ever pays for a full read.
|
|
40
|
+
*
|
|
41
|
+
* 2. EMIT decision — requires actual claim evidence (an event whose
|
|
42
|
+
* `type === 'claim'`) for the pair, not merely "some matching event
|
|
43
|
+
* exists". That evidence is read from the tail when the tail already
|
|
44
|
+
* contains a `claim` event for the pair (zero extra I/O); otherwise
|
|
45
|
+
* exactly ONE full read runs to confirm one way or the other (paid at
|
|
46
|
+
* most once per throttle window per session, never on throttled
|
|
47
|
+
* checks). No claim found anywhere -> skip with reason 'no-claim'. This
|
|
48
|
+
* also neutralizes pre-existing orphan heartbeats already sitting in
|
|
49
|
+
* old streams (a stream containing only heartbeats for a pair, with no
|
|
50
|
+
* claim ever recorded, now always resolves to 'no-claim').
|
|
51
|
+
*
|
|
52
|
+
* Fails open: never throws, never blocks the calling hook, stderr diagnostic
|
|
53
|
+
* on error only (mirrors the #287 fail-open convention already used by
|
|
54
|
+
* actor-identity.js / liveness-read.js consumers).
|
|
55
|
+
*
|
|
56
|
+
* Exports:
|
|
57
|
+
* maybeEmitHeartbeat({ cwd, env, now }) → { emitted: boolean, reason?: string,
|
|
58
|
+
* conflict?: { actor: string, lastAt: string, ttlSeconds: number } }
|
|
59
|
+
*
|
|
60
|
+
* Mid-turn conflict detection (issue #320, AC3/AC5): every call already reads a bounded
|
|
61
|
+
* tail of the liveness stream to throttle/emit our OWN heartbeat. That same in-memory tail
|
|
62
|
+
* (or, on the rare full-read fallback path, that full read) is additionally passed through
|
|
63
|
+
* `freshHolders()` (./liveness-read) to detect another actor's fresh claim on OUR OWN held
|
|
64
|
+
* subject — zero added I/O. The optional `conflict` field is attached whenever we've
|
|
65
|
+
* confirmed we hold a live claim on the subject (regardless of whether our own heartbeat
|
|
66
|
+
* emits or throttles this call) and some other actor's most recent event for the subject is
|
|
67
|
+
* strictly newer than OUR OWN last recorded event for it prior to this call — a
|
|
68
|
+
* stream-derived episode watermark, not a wall-clock timer, so a still-fresh conflicting
|
|
69
|
+
* claim already seen on a prior call never re-fires; only a genuinely newer event from the
|
|
70
|
+
* conflicting actor does (our own heartbeat write, once appended, becomes the new watermark
|
|
71
|
+
* for the next call). Never treated as evidence for OUR OWN claim/throttle decisions above —
|
|
72
|
+
* this only ADDS a field, it never changes the existing `emitted`/`reason` semantics.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
const fs = require('fs');
|
|
76
|
+
const path = require('path');
|
|
77
|
+
|
|
78
|
+
const { isLivenessEnabled, resolveHeartbeatThrottleSeconds } = require('./liveness-policy');
|
|
79
|
+
const { livenessStreamFile, appendLivenessEvent } = require('./liveness-write');
|
|
80
|
+
const { readLivenessEvents, readLivenessEventsTail, freshHolders } = require('./liveness-read');
|
|
81
|
+
const { resolveActor, isUnresolvedActor, sanitizeSegment } = require('./actor-identity');
|
|
82
|
+
const { flowAgentsArtifactRoot } = require('./local-artifact-paths');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a caller-supplied `now` (Date, ISO string, or omitted) to epoch ms.
|
|
86
|
+
* Falls back to `Date.now()` for anything that does not parse cleanly.
|
|
87
|
+
*
|
|
88
|
+
* @param {Date|string|undefined} now
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function resolveNowMs(now) {
|
|
92
|
+
if (now instanceof Date) {
|
|
93
|
+
const ms = now.getTime();
|
|
94
|
+
return Number.isFinite(ms) ? ms : Date.now();
|
|
95
|
+
}
|
|
96
|
+
if (typeof now === 'string' && now.trim()) {
|
|
97
|
+
const ms = Date.parse(now);
|
|
98
|
+
return Number.isFinite(ms) ? ms : Date.now();
|
|
99
|
+
}
|
|
100
|
+
return Date.now();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read `current.json`'s `active_slug` from the given artifact root, sanitized through the same
|
|
105
|
+
* charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
|
|
106
|
+
* sec-LOW: `current.json` is a local file that could be hand-edited or otherwise hostile —
|
|
107
|
+
* `active_slug` must never be trusted verbatim before it is used as a JSONL grouping key /
|
|
108
|
+
* emitted `subjectId` or compared against event data). Tolerates a missing file or malformed
|
|
109
|
+
* JSON (returns "").
|
|
110
|
+
*
|
|
111
|
+
* @param {string} root
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function readActiveSlug(root) {
|
|
115
|
+
let raw = '';
|
|
116
|
+
try {
|
|
117
|
+
raw = fs.readFileSync(path.join(root, 'current.json'), 'utf8');
|
|
118
|
+
} catch {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
const activeSlug = (parsed && parsed.active_slug) || '';
|
|
124
|
+
if (!activeSlug) return '';
|
|
125
|
+
// sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
|
|
126
|
+
// fallback is not a legitimate slug, so treat it as "no active slug" here rather than
|
|
127
|
+
// matching against a subject that was never really claimed.
|
|
128
|
+
const sanitized = sanitizeSegment(activeSlug);
|
|
129
|
+
return sanitized === 'unknown' ? '' : sanitized;
|
|
130
|
+
} catch {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Filter a list of parsed liveness events down to the ones matching a given
|
|
137
|
+
* (subjectId, actor) pair. Shared by the bounded tail read and the full-read
|
|
138
|
+
* fallback so both filter identically.
|
|
139
|
+
*
|
|
140
|
+
* @param {object[]} events
|
|
141
|
+
* @param {string} slug
|
|
142
|
+
* @param {string} actor
|
|
143
|
+
* @returns {object[]}
|
|
144
|
+
*/
|
|
145
|
+
function filterMatchingPair(events, slug, actor) {
|
|
146
|
+
return events.filter(
|
|
147
|
+
(e) => e && typeof e === 'object' && e.subjectId === slug && e.actor === actor
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Detect another actor's fresher claim on the SAME subject as our own, using an
|
|
153
|
+
* already-loaded events array — the bounded tail in the common case, or the full-read
|
|
154
|
+
* fallback array in the rare case the EMIT decision already paid for one — never an
|
|
155
|
+
* additional `fs` call (issue #320, AC3). Reuses the canonical `freshHolders()` grouping/
|
|
156
|
+
* freshness rules from `./liveness-read` (the same helper `workflow-steering.js`'s ambient
|
|
157
|
+
* per-turn digest already relies on) instead of re-deriving freshness logic here.
|
|
158
|
+
*
|
|
159
|
+
* Episode-throttled per AC5: a holder is only reported when its `lastAt` is strictly newer
|
|
160
|
+
* than `ourLastAt` (our own last recorded event for this subject, captured BEFORE this call
|
|
161
|
+
* potentially appends its own heartbeat) — an unchanged, still-fresh conflicting claim already
|
|
162
|
+
* surfaced on a prior call never re-fires; only a genuinely new event from the conflicting
|
|
163
|
+
* actor does. This is a stream-derived watermark comparison, never a wall-clock timer. When
|
|
164
|
+
* more than one other actor qualifies, the single most-recently-updated one is reported.
|
|
165
|
+
*
|
|
166
|
+
* Known, disclosed limitation (mirrors this module's own orphan-heartbeat caveat above): if
|
|
167
|
+
* the conflicting actor's own `claim` event (carrying `ttlSeconds`) has scrolled outside the
|
|
168
|
+
* 64KB tail window, `freshHolders` defaults that holder's `ttlSeconds` to 1800s using only the
|
|
169
|
+
* heartbeat events visible in the tail — the same accepted-and-disclosed class of limitation
|
|
170
|
+
* the module header already names for the caller's OWN pair.
|
|
171
|
+
*
|
|
172
|
+
* @param {object[]} events
|
|
173
|
+
* @param {string} slug
|
|
174
|
+
* @param {string} actor
|
|
175
|
+
* @param {number} nowMs
|
|
176
|
+
* @param {string} ourLastAt
|
|
177
|
+
* @returns {{actor: string, lastAt: string, ttlSeconds: number}|undefined}
|
|
178
|
+
*/
|
|
179
|
+
function computeConflict(events, slug, actor, nowMs, ourLastAt) {
|
|
180
|
+
const ourLastAtMs = Date.parse(ourLastAt);
|
|
181
|
+
const others = freshHolders(events, slug, actor, nowMs).filter((h) => {
|
|
182
|
+
const holderAtMs = Date.parse(h.lastAt);
|
|
183
|
+
return Number.isFinite(holderAtMs) && Number.isFinite(ourLastAtMs) && holderAtMs > ourLastAtMs;
|
|
184
|
+
});
|
|
185
|
+
if (!others.length) return undefined;
|
|
186
|
+
const winner = others.reduce((a, b) => (Date.parse(b.lastAt) > Date.parse(a.lastAt) ? b : a));
|
|
187
|
+
// Sanitize at this single choke point (issue #320 fix iteration 1, sec-CRITICAL/HIGH F1/F2):
|
|
188
|
+
// `winner.actor`/`winner.lastAt` are read straight off the multi-writer append-only
|
|
189
|
+
// liveness/events.jsonl stream, which ANY process can append to (a second agent's shell, a
|
|
190
|
+
// hand-edit) bypassing the CLI write-side `sanitizeSegment` entirely. These values flow
|
|
191
|
+
// UNSANITIZED into agent-facing channels (hookSpecificOutput.additionalContext / stderr) in
|
|
192
|
+
// all four wrapper hooks below — the exact #287 injection class in a new, more directly
|
|
193
|
+
// agent-facing channel. Sanitizing once here, at the sole place `conflict` is constructed,
|
|
194
|
+
// means every caller (all four wrappers) inherits clean values with no per-wrapper defense
|
|
195
|
+
// to forget. `actor` reuses the same allowlist+64-char-cap `sanitizeSegment` the write side
|
|
196
|
+
// already applies to legitimate actors (a no-op on them); `lastAt` is structurally
|
|
197
|
+
// eliminated by re-serializing through Date.parse/toISOString — a timestamp can never carry
|
|
198
|
+
// control chars once canonicalized this way. `ttlSeconds` is already a Number — left as-is.
|
|
199
|
+
const winnerLastAtMs = Date.parse(winner.lastAt);
|
|
200
|
+
const sanitizedLastAt = Number.isFinite(winnerLastAtMs) ? new Date(winnerLastAtMs).toISOString() : '';
|
|
201
|
+
return { actor: sanitizeSegment(winner.actor), lastAt: sanitizedLastAt, ttlSeconds: winner.ttlSeconds };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Attach an optional `conflict` field to a result object, omitting the key entirely when no
|
|
206
|
+
* conflict was detected (rather than an explicit `conflict: undefined`), so callers can rely
|
|
207
|
+
* on a plain `if (result.conflict)` check.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} result
|
|
210
|
+
* @param {{actor: string, lastAt: string, ttlSeconds: number}|undefined} conflict
|
|
211
|
+
* @returns {object}
|
|
212
|
+
*/
|
|
213
|
+
function attachConflictField(result, conflict) {
|
|
214
|
+
return conflict ? { ...result, conflict } : result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Emit a throttled tool-activity liveness heartbeat for the resolved
|
|
219
|
+
* actor/subject, if (and only if) a fresh, unreleased claim already exists.
|
|
220
|
+
* Never creates a new claim. Fails open — always resolves to a result
|
|
221
|
+
* object, never throws.
|
|
222
|
+
*
|
|
223
|
+
* See the module header for the full F8(ii) throttle/emit split. Summary:
|
|
224
|
+
* - enabled? -> slug present? -> resolveActor() -> bounded tail read, in
|
|
225
|
+
* that order (F3), so a disabled repo or one with no active session
|
|
226
|
+
* never pays the resolveActor() ancestry `ps` spawn cost.
|
|
227
|
+
* - THROTTLE decision reads only the bounded tail (never a full read):
|
|
228
|
+
* a release found there refuses; a fresh event there throttles.
|
|
229
|
+
* - EMIT decision requires an actual `claim` event for the pair: taken
|
|
230
|
+
* from the tail when already present there, otherwise exactly one full
|
|
231
|
+
* read confirms (or refutes) it. No claim anywhere -> 'no-claim'.
|
|
232
|
+
* - CONFLICT check (issue #320, AC3/AC5) reuses whichever events array (tail, or the rare
|
|
233
|
+
* full-read fallback) already confirmed OUR OWN live claim — zero added I/O — and passes
|
|
234
|
+
* it through `freshHolders()` to find another actor's claim on the SAME subject. Only
|
|
235
|
+
* surfaced when that other actor's most recent event is strictly newer than our own last
|
|
236
|
+
* recorded event for the subject prior to this call (a stream-derived watermark, not a
|
|
237
|
+
* wall-clock timer), so it fires once per genuinely new conflicting event, independent of
|
|
238
|
+
* whether our own heartbeat emits or throttles this call.
|
|
239
|
+
*
|
|
240
|
+
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string}} [opts]
|
|
241
|
+
* @returns {{emitted: boolean, reason?: string, conflict?: {actor: string, lastAt: string, ttlSeconds: number}}}
|
|
242
|
+
*/
|
|
243
|
+
function maybeEmitHeartbeat(opts = {}) {
|
|
244
|
+
const { cwd = process.cwd(), env = process.env } = opts || {};
|
|
245
|
+
try {
|
|
246
|
+
if (!isLivenessEnabled(env)) {
|
|
247
|
+
return { emitted: false, reason: 'disabled' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const root = flowAgentsArtifactRoot(cwd);
|
|
251
|
+
const slug = readActiveSlug(root);
|
|
252
|
+
if (!slug) {
|
|
253
|
+
return { emitted: false, reason: 'no-current' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { actor } = resolveActor(env);
|
|
257
|
+
if (isUnresolvedActor(actor)) {
|
|
258
|
+
return { emitted: false, reason: 'actor-unresolved' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const streamPath = livenessStreamFile(root);
|
|
262
|
+
const nowMs = resolveNowMs(opts.now);
|
|
263
|
+
const throttleMs = resolveHeartbeatThrottleSeconds(env) * 1000;
|
|
264
|
+
|
|
265
|
+
// ─── 1. THROTTLE decision — bounded tail only, never a full read (F8(ii)) ──
|
|
266
|
+
// Capture the unfiltered tail buffer once (issue #320, AC3) so the CONFLICT check below
|
|
267
|
+
// can reuse the exact same in-memory array on every return path — zero additional `fs`
|
|
268
|
+
// calls beyond what the throttle/emit decisions already pay for.
|
|
269
|
+
const tailEvents = readLivenessEventsTail(streamPath);
|
|
270
|
+
let matching = filterMatchingPair(tailEvents, slug, actor);
|
|
271
|
+
let last = matching.length ? matching[matching.length - 1] : null;
|
|
272
|
+
if (last) {
|
|
273
|
+
if (last.type === 'release') {
|
|
274
|
+
return { emitted: false, reason: 'released' };
|
|
275
|
+
}
|
|
276
|
+
const lastAtMs = Date.parse(last.at);
|
|
277
|
+
if (Number.isFinite(lastAtMs) && nowMs - lastAtMs < throttleMs) {
|
|
278
|
+
// CONFLICT check (issue #320, AC3/AC5): only attached here when the tail ALREADY
|
|
279
|
+
// shows a genuine `claim` for our own pair — zero extra I/O, never a full read on
|
|
280
|
+
// this hot, most-common return path (preserving the "neither outcome ever pays for a
|
|
281
|
+
// full read" invariant documented above). When our own claim history has scrolled
|
|
282
|
+
// outside the tail window, this rare edge case simply omits the conflict field on
|
|
283
|
+
// THIS call rather than paying for a read; a subsequent call (once genuinely out of
|
|
284
|
+
// our own throttle window, or once the EMIT decision's full-read fallback runs) will
|
|
285
|
+
// confirm it as usual.
|
|
286
|
+
const ourClaimVisibleInTail = matching.some((e) => e.type === 'claim');
|
|
287
|
+
const conflict = ourClaimVisibleInTail
|
|
288
|
+
? computeConflict(tailEvents, slug, actor, nowMs, last.at)
|
|
289
|
+
: undefined;
|
|
290
|
+
return attachConflictField({ emitted: false, reason: 'throttled' }, conflict);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── 2. EMIT decision — requires actual claim evidence (F8(ii)) ───────────
|
|
295
|
+
// A bare heartbeat (or a release we've already ruled out above) is never sufficient
|
|
296
|
+
// evidence on its own that a claim was ever made — that assumption is exactly the
|
|
297
|
+
// orphan-heartbeat defect this closes. Prefer evidence already visible in the tail
|
|
298
|
+
// (zero extra I/O); only pay for one full read when the tail didn't already settle it
|
|
299
|
+
// (either it had no matching events at all, or it had some but none was a claim).
|
|
300
|
+
let hasClaim = matching.some((e) => e.type === 'claim');
|
|
301
|
+
// `holderEvents` is whichever events array (tail, or the full read below) already
|
|
302
|
+
// confirmed our own claim — reused by the CONFLICT check (issue #320, AC3) below with no
|
|
303
|
+
// additional `fs` call of its own.
|
|
304
|
+
let holderEvents = tailEvents;
|
|
305
|
+
if (!hasClaim) {
|
|
306
|
+
const fullEvents = readLivenessEvents(streamPath);
|
|
307
|
+
holderEvents = fullEvents;
|
|
308
|
+
matching = filterMatchingPair(fullEvents, slug, actor);
|
|
309
|
+
last = matching.length ? matching[matching.length - 1] : null;
|
|
310
|
+
if (!last) {
|
|
311
|
+
return { emitted: false, reason: 'no-claim' };
|
|
312
|
+
}
|
|
313
|
+
if (last.type === 'release') {
|
|
314
|
+
return { emitted: false, reason: 'released' };
|
|
315
|
+
}
|
|
316
|
+
hasClaim = matching.some((e) => e.type === 'claim');
|
|
317
|
+
if (!hasClaim) {
|
|
318
|
+
return { emitted: false, reason: 'no-claim' };
|
|
319
|
+
}
|
|
320
|
+
// The tail was entirely empty for this pair (the only case reachable here with the
|
|
321
|
+
// throttle decision above never having run against `last`) — re-check the throttle
|
|
322
|
+
// window now that the full read has supplied the true last event.
|
|
323
|
+
const lastAtMs = Date.parse(last.at);
|
|
324
|
+
if (Number.isFinite(lastAtMs) && nowMs - lastAtMs < throttleMs) {
|
|
325
|
+
return attachConflictField(
|
|
326
|
+
{ emitted: false, reason: 'throttled' },
|
|
327
|
+
computeConflict(holderEvents, slug, actor, nowMs, last.at)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── 3. CONFLICT check — same in-memory buffer as above, zero added I/O (issue #320,
|
|
333
|
+
// AC3/AC5) ──────────────────────────────────────────────────────────────────────────
|
|
334
|
+
// Reached only once `hasClaim` is confirmed true (a live, unreleased claim on `slug`) and
|
|
335
|
+
// we are about to emit our own heartbeat. `last.at` here is OUR OWN last recorded event
|
|
336
|
+
// for this subject BEFORE this call's write below — the watermark AC5's stream-derived
|
|
337
|
+
// episode throttle compares against, so an unchanged, still-fresh conflicting claim seen
|
|
338
|
+
// on a prior call never re-fires; only a genuinely newer event from the conflicting actor
|
|
339
|
+
// does, because our own heartbeat write below becomes the new watermark for the next call.
|
|
340
|
+
const conflict = computeConflict(holderEvents, slug, actor, nowMs, last.at);
|
|
341
|
+
|
|
342
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
343
|
+
appendLivenessEvent(root, {
|
|
344
|
+
type: 'heartbeat',
|
|
345
|
+
subjectId: slug,
|
|
346
|
+
actor,
|
|
347
|
+
at: nowIso,
|
|
348
|
+
source: 'tool-activity',
|
|
349
|
+
});
|
|
350
|
+
return attachConflictField({ emitted: true }, conflict);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
try {
|
|
353
|
+
process.stderr.write(`[liveness-heartbeat] skipped: ${err.message}\n`);
|
|
354
|
+
} catch {
|
|
355
|
+
/* best-effort diagnostic only */
|
|
356
|
+
}
|
|
357
|
+
return { emitted: false, reason: 'error' };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = { maybeEmitHeartbeat };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* liveness-policy.js — shared pure-CJS liveness policy predicates
|
|
4
|
+
*
|
|
5
|
+
* Zero external dependencies. Consumed by:
|
|
6
|
+
* - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
|
|
7
|
+
* - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
|
|
8
|
+
*
|
|
9
|
+
* Purpose (issue #288): the ONE definition of the liveness on/off predicate,
|
|
10
|
+
* the claim TTL default, and the heartbeat throttle default, so
|
|
11
|
+
* `livenessEnabled()`/the claim-path TTL literal in workflow-sidecar.ts and
|
|
12
|
+
* the new tool-activity heartbeat (liveness-heartbeat.js) can never disagree
|
|
13
|
+
* about what "enabled" or "default TTL" means — the same consume-never-fork
|
|
14
|
+
* discipline already used for actor resolution (actor-identity.js) and
|
|
15
|
+
* liveness-stream reads (liveness-read.js).
|
|
16
|
+
*
|
|
17
|
+
* Default-on / opt-out (#288 supersedes #166's opt-in default per ADR 0021
|
|
18
|
+
* §3): presence is ambient unless explicitly disabled via one of
|
|
19
|
+
* `off|0|false|no|disabled` (case-insensitive, whitespace-trimmed, and with
|
|
20
|
+
* zero-width/format characters — U+200B/U+200C/U+200D/U+FEFF — stripped
|
|
21
|
+
* before comparison, F4 #288 fix iteration 1, sec-LOW: `"off\u200B"` must
|
|
22
|
+
* still disable, not silently stay enabled by comparing unequal to the
|
|
23
|
+
* `"off"` token). Every other value — including unset/empty — is treated as
|
|
24
|
+
* enabled.
|
|
25
|
+
*
|
|
26
|
+
* Exports:
|
|
27
|
+
* DEFAULT_TTL_SECONDS = 1800
|
|
28
|
+
* DEFAULT_HEARTBEAT_THROTTLE_SECONDS = 60
|
|
29
|
+
* isLivenessEnabled(env) → boolean
|
|
30
|
+
* resolveTtlSeconds(env) → number
|
|
31
|
+
* resolveHeartbeatThrottleSeconds(env) → number
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Explicit opt-out tokens (case-insensitive, trimmed). Everything else — including
|
|
35
|
+
* unset/empty — is enabled. */
|
|
36
|
+
const DISABLED_TOKENS = new Set(['off', '0', 'false', 'no', 'disabled']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Zero-width/format characters (U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER,
|
|
40
|
+
* U+200D ZERO WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM) that render invisibly but
|
|
41
|
+
* would otherwise make an opt-out token compare unequal to its canonical form (F4, #288 fix
|
|
42
|
+
* iteration 1, sec-LOW).
|
|
43
|
+
*/
|
|
44
|
+
const ZERO_WIDTH_CHARS_RE = /[\u200B-\u200D\uFEFF]/g;
|
|
45
|
+
|
|
46
|
+
/** Default liveness claim TTL, in seconds (30 minutes). */
|
|
47
|
+
const DEFAULT_TTL_SECONDS = 1800;
|
|
48
|
+
|
|
49
|
+
/** Default minimum spacing between tool-activity heartbeats for one subject+actor, in seconds. */
|
|
50
|
+
const DEFAULT_HEARTBEAT_THROTTLE_SECONDS = 60;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default-on / opt-out predicate for `FLOW_AGENTS_LIVENESS`. Takes `env` as an explicit
|
|
54
|
+
* param (mirroring actor-identity.js's style) rather than reading `process.env` internally,
|
|
55
|
+
* so it stays unit-testable.
|
|
56
|
+
*
|
|
57
|
+
* @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
|
|
58
|
+
* @returns {boolean} false only when FLOW_AGENTS_LIVENESS is an explicit off-token; true otherwise
|
|
59
|
+
*/
|
|
60
|
+
function isLivenessEnabled(env = process.env) {
|
|
61
|
+
env = env || {};
|
|
62
|
+
const value = String(env.FLOW_AGENTS_LIVENESS ?? '')
|
|
63
|
+
.replace(ZERO_WIDTH_CHARS_RE, '')
|
|
64
|
+
.trim()
|
|
65
|
+
.toLowerCase();
|
|
66
|
+
return !DISABLED_TOKENS.has(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strict positive-integer literal: one or more ASCII digits, nothing else (F7, #287/#288 fix
|
|
70
|
+
* iterations: rejects hex (`0x10`), exponential (`1e3`), decimal, signed, and whitespace-padded
|
|
71
|
+
* coercions that JS's `Number()` would otherwise silently accept). */
|
|
72
|
+
const STRICT_POSITIVE_INT_RE = /^[0-9]+$/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a positive-integer env override, falling back to a default when the raw value is
|
|
76
|
+
* missing, non-numeric, or not strictly positive. Uses a strict `/^[0-9]+$/` literal match
|
|
77
|
+
* (F7) rather than `Number()` coercion, so inputs like `"0x10"` (16) or `"1e3"` (1000) — valid
|
|
78
|
+
* per `Number()` but not a plain positive-integer literal — fall back to the default instead of
|
|
79
|
+
* silently being accepted.
|
|
80
|
+
*
|
|
81
|
+
* @param {*} rawValue
|
|
82
|
+
* @param {number} fallback
|
|
83
|
+
* @returns {number}
|
|
84
|
+
*/
|
|
85
|
+
function parsePositiveIntOr(rawValue, fallback) {
|
|
86
|
+
const trimmed = String(rawValue ?? '').trim();
|
|
87
|
+
if (!STRICT_POSITIVE_INT_RE.test(trimmed)) return fallback;
|
|
88
|
+
const parsed = Number(trimmed);
|
|
89
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
90
|
+
return Math.floor(parsed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the liveness claim TTL, in seconds: `FLOW_AGENTS_LIVENESS_TTL_SECONDS` if set to a
|
|
95
|
+
* positive number, else DEFAULT_TTL_SECONDS.
|
|
96
|
+
*
|
|
97
|
+
* @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function resolveTtlSeconds(env = process.env) {
|
|
101
|
+
env = env || {};
|
|
102
|
+
return parsePositiveIntOr(env.FLOW_AGENTS_LIVENESS_TTL_SECONDS, DEFAULT_TTL_SECONDS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the heartbeat throttle window, in seconds: minimum spacing between tool-activity
|
|
107
|
+
* heartbeats for one subject+actor pair. `FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS` if
|
|
108
|
+
* set to a positive number, else DEFAULT_HEARTBEAT_THROTTLE_SECONDS.
|
|
109
|
+
*
|
|
110
|
+
* @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
function resolveHeartbeatThrottleSeconds(env = process.env) {
|
|
114
|
+
env = env || {};
|
|
115
|
+
return parsePositiveIntOr(
|
|
116
|
+
env.FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS,
|
|
117
|
+
DEFAULT_HEARTBEAT_THROTTLE_SECONDS
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
DEFAULT_TTL_SECONDS,
|
|
123
|
+
DEFAULT_HEARTBEAT_THROTTLE_SECONDS,
|
|
124
|
+
isLivenessEnabled,
|
|
125
|
+
resolveTtlSeconds,
|
|
126
|
+
resolveHeartbeatThrottleSeconds,
|
|
127
|
+
};
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* liveness-read.js — shared pure-CJS liveness freshness helper
|
|
4
4
|
*
|
|
5
5
|
* Zero external dependencies. Consumed by:
|
|
6
|
-
* - scripts/hooks/workflow-steering.js
|
|
7
|
-
* -
|
|
6
|
+
* - scripts/hooks/workflow-steering.js (CJS, direct require)
|
|
7
|
+
* - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
|
|
8
|
+
* - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
|
|
8
9
|
*
|
|
9
10
|
* Exports:
|
|
10
11
|
* readLivenessEvents(streamPath) → AnyObj[] (tolerates malformed lines)
|
|
12
|
+
* readLivenessEventsTail(streamPath, tailBytes?) → AnyObj[] (bounded I/O; see below)
|
|
11
13
|
* freshHolders(events, slug, selfActor, nowMs) → holder[]
|
|
12
14
|
*
|
|
13
15
|
* freshHolders returns, for each actor (other than selfActor) with a
|
|
@@ -26,6 +28,32 @@
|
|
|
26
28
|
|
|
27
29
|
const fs = require('fs');
|
|
28
30
|
|
|
31
|
+
/** Default bounded-tail read size, in bytes (F3, #288 fix iteration 1). */
|
|
32
|
+
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a raw newline-delimited JSONL blob into an array of parsed event objects.
|
|
36
|
+
* Tolerates blank lines and malformed lines (silently skips both). Shared by
|
|
37
|
+
* readLivenessEvents (full read) and readLivenessEventsTail (bounded read) so both
|
|
38
|
+
* paths parse identically.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} raw
|
|
41
|
+
* @returns {object[]}
|
|
42
|
+
*/
|
|
43
|
+
function parseEventLines(raw) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const line of raw.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed) continue;
|
|
48
|
+
try {
|
|
49
|
+
out.push(JSON.parse(trimmed));
|
|
50
|
+
} catch {
|
|
51
|
+
/* skip malformed line */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
/**
|
|
30
58
|
* Read a liveness JSONL stream from the given path.
|
|
31
59
|
* Tolerates missing file (returns []) and malformed lines (silently skips).
|
|
@@ -40,17 +68,82 @@ function readLivenessEvents(streamPath) {
|
|
|
40
68
|
} catch {
|
|
41
69
|
return [];
|
|
42
70
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
return parseEventLines(raw);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read only the last `tailBytes` (default 64KB) of a liveness JSONL stream, newline-aligned
|
|
76
|
+
* so a partial line at the truncation boundary is dropped rather than mis-parsed, instead of
|
|
77
|
+
* reading the whole file (F3, #288 fix iteration 1, sec-MED + cr-MED: `maybeEmitHeartbeat` in
|
|
78
|
+
* liveness-heartbeat.js previously full-read `events.jsonl` on every tool call — 374ms measured
|
|
79
|
+
* at an 80MB stream — a cost paid on the hot `postToolUse` path). Bounded I/O regardless of
|
|
80
|
+
* total stream size: at most `tailBytes` bytes are ever read off disk.
|
|
81
|
+
*
|
|
82
|
+
* Because the stream is append-only, the tail of the file is always its most recent portion —
|
|
83
|
+
* so if a given `(subjectId, actor)` pair's most recent event lies within the tail window, this
|
|
84
|
+
* function returns it correctly every time; only an event pair whose *every* occurrence lies
|
|
85
|
+
* entirely before the tail window is invisible here (the caller's job, not this function's, is
|
|
86
|
+
* to fall back to `readLivenessEvents` in that rare case — see liveness-heartbeat.js).
|
|
87
|
+
*
|
|
88
|
+
* Tolerates a missing file (returns []) exactly like readLivenessEvents.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} streamPath
|
|
91
|
+
* @param {number} [tailBytes] Defaults to 64KB (DEFAULT_TAIL_BYTES).
|
|
92
|
+
* @returns {object[]}
|
|
93
|
+
*/
|
|
94
|
+
function readLivenessEventsTail(streamPath, tailBytes = DEFAULT_TAIL_BYTES) {
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
const size = fs.statSync(streamPath).size;
|
|
98
|
+
if (size <= 0) return [];
|
|
99
|
+
const start = size > tailBytes ? size - tailBytes : 0;
|
|
100
|
+
const length = size - start;
|
|
101
|
+
const buffer = Buffer.alloc(length);
|
|
102
|
+
fd = fs.openSync(streamPath, 'r');
|
|
103
|
+
// F9 (#288 fix iteration 2, LOW): a single fs.readSync() call is not guaranteed to fill the
|
|
104
|
+
// whole requested length in one shot (short reads are a documented POSIX possibility, and can
|
|
105
|
+
// also occur here if the file is truncated/rotated between the statSync() above and this read).
|
|
106
|
+
// Loop until either the full requested `length` has been read or EOF is hit (bytesRead === 0).
|
|
107
|
+
let bytesReadTotal = 0;
|
|
108
|
+
while (bytesReadTotal < length) {
|
|
109
|
+
const bytesRead = fs.readSync(fd, buffer, bytesReadTotal, length - bytesReadTotal, start + bytesReadTotal);
|
|
110
|
+
if (bytesRead <= 0) break; // EOF before the requested tail length was fully read
|
|
111
|
+
bytesReadTotal += bytesRead;
|
|
112
|
+
}
|
|
113
|
+
if (bytesReadTotal < length) {
|
|
114
|
+
// Short read: decoding a partially-filled buffer risks silently dropping or mis-parsing the
|
|
115
|
+
// last (possibly truncated) record. Never decode zero/partial bytes here — degrade to a full
|
|
116
|
+
// readFileSync of the same path instead, which re-reads the file from scratch and is immune
|
|
117
|
+
// to this race entirely.
|
|
118
|
+
try {
|
|
119
|
+
fs.closeSync(fd);
|
|
120
|
+
} catch {
|
|
121
|
+
/* best-effort close only */
|
|
122
|
+
}
|
|
123
|
+
fd = undefined;
|
|
124
|
+
return readLivenessEvents(streamPath);
|
|
125
|
+
}
|
|
126
|
+
let raw = buffer.toString('utf8');
|
|
127
|
+
if (start > 0) {
|
|
128
|
+
// Newline-aligned: the read may begin mid-line (the byte at `start` is not
|
|
129
|
+
// necessarily the start of a JSON record) — drop the possibly-truncated partial
|
|
130
|
+
// first line rather than risk mis-parsing (or worse, silently accepting) a
|
|
131
|
+
// corrupted fragment.
|
|
132
|
+
const newlineIndex = raw.indexOf('\n');
|
|
133
|
+
raw = newlineIndex === -1 ? '' : raw.slice(newlineIndex + 1);
|
|
134
|
+
}
|
|
135
|
+
return parseEventLines(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
} finally {
|
|
139
|
+
if (fd !== undefined) {
|
|
140
|
+
try {
|
|
141
|
+
fs.closeSync(fd);
|
|
142
|
+
} catch {
|
|
143
|
+
/* best-effort close only */
|
|
144
|
+
}
|
|
51
145
|
}
|
|
52
146
|
}
|
|
53
|
-
return out;
|
|
54
147
|
}
|
|
55
148
|
|
|
56
149
|
/**
|
|
@@ -110,4 +203,4 @@ function freshHolders(events, slug, selfActor, nowMs) {
|
|
|
110
203
|
return result;
|
|
111
204
|
}
|
|
112
205
|
|
|
113
|
-
module.exports = { readLivenessEvents, freshHolders };
|
|
206
|
+
module.exports = { readLivenessEvents, readLivenessEventsTail, freshHolders };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* liveness-write.js — shared pure-CJS liveness stream writer
|
|
4
|
+
*
|
|
5
|
+
* Zero external dependencies. Consumed by:
|
|
6
|
+
* - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
|
|
7
|
+
* - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
|
|
8
|
+
*
|
|
9
|
+
* Purpose (issue #288): the ONE writer for `liveness/events.jsonl`, lifted
|
|
10
|
+
* verbatim from `src/cli/workflow-sidecar.ts`'s inline `livenessStreamFile`/
|
|
11
|
+
* `appendLivenessEvent` so both the CLI and the hook wrappers share one
|
|
12
|
+
* implementation (mirroring the existing `liveness-read.js`/`actor-identity.js`
|
|
13
|
+
* sharing pattern) instead of forking the append shape a second time.
|
|
14
|
+
*
|
|
15
|
+
* Exports:
|
|
16
|
+
* livenessStreamFile(root) → string (absolute path to liveness/events.jsonl)
|
|
17
|
+
* appendLivenessEvent(root, evt) → void (mkdir -p parent, append one JSON line)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the path to the shared liveness event stream for a given artifact root.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} root Artifact root (e.g. `.kontourai/flow-agents`)
|
|
27
|
+
* @returns {string} `<root>/liveness/events.jsonl`
|
|
28
|
+
*/
|
|
29
|
+
function livenessStreamFile(root) {
|
|
30
|
+
return path.join(root, 'liveness', 'events.jsonl');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Append one liveness event to the shared stream, creating the parent directory if needed.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root Artifact root (e.g. `.kontourai/flow-agents`)
|
|
37
|
+
* @param {object} evt Event object (written as one JSON line, newline-terminated)
|
|
38
|
+
* @returns {void}
|
|
39
|
+
*/
|
|
40
|
+
function appendLivenessEvent(root, evt) {
|
|
41
|
+
const file = livenessStreamFile(root);
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { livenessStreamFile, appendLivenessEvent };
|