principles-disciple 1.71.0 → 1.73.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/openclaw.plugin.json +10 -5
- package/package.json +17 -19
- package/scripts/acceptance-test.mjs +16 -73
- package/scripts/sync-plugin.mjs +382 -77
- package/src/commands/archive-impl.ts +2 -1
- package/src/commands/capabilities.ts +2 -2
- package/src/commands/context.ts +2 -2
- package/src/commands/disable-impl.ts +2 -1
- package/src/commands/evolution-status.ts +16 -16
- package/src/commands/export.ts +12 -67
- package/src/commands/pain.ts +91 -1
- package/src/commands/principle-rollback.ts +2 -1
- package/src/commands/promote-impl.ts +7 -43
- package/src/commands/rollback-impl.ts +2 -1
- package/src/commands/rollback.ts +2 -1
- package/src/commands/samples.ts +2 -1
- package/src/commands/thinking-os.ts +2 -1
- package/src/config/errors.ts +18 -2
- package/src/constants/diagnostician.ts +2 -2
- package/src/constants/tools.ts +2 -1
- package/src/core/__tests__/focus-history.test.ts +210 -0
- package/src/core/config.ts +1 -1
- package/src/core/confirm-first-gate.ts +255 -0
- package/src/core/correction-cue-learner.ts +2 -136
- package/src/core/correction-types.ts +16 -88
- package/src/core/dictionary.ts +19 -20
- package/src/core/empathy-keyword-matcher.ts +17 -289
- package/src/core/empathy-types.ts +18 -229
- package/src/core/event-log.ts +38 -132
- package/src/core/evolution-reducer.ts +21 -2
- package/src/core/evolution-types.ts +76 -464
- package/src/core/file-store.ts +80 -0
- package/src/core/focus-history.ts +228 -955
- package/src/core/local-worker-routing.ts +34 -314
- package/src/core/merge-gate-audit.ts +0 -195
- package/src/core/pain-diagnostic-gate.ts +154 -0
- package/src/core/pain-signal.ts +21 -138
- package/src/core/pain.ts +15 -88
- package/src/core/pd-task-reconciler.ts +26 -115
- package/src/core/pd-task-service.ts +9 -9
- package/src/core/pd-task-types.ts +23 -127
- package/src/core/principle-compiler/__tests__/compiler-replay-gate.test.ts +174 -0
- package/src/core/principle-compiler/code-validator.ts +15 -42
- package/src/core/principle-compiler/compiler.ts +100 -15
- package/src/core/principle-compiler/index.ts +5 -2
- package/src/core/principle-compiler/template-generator.ts +4 -104
- package/src/core/principle-injection.ts +10 -202
- package/src/core/principle-internalization/filesystem-lifecycle-datasource.ts +42 -0
- package/src/core/principle-internalization/lifecycle-read-model.ts +39 -242
- package/src/core/principle-internalization/principle-lifecycle-service.ts +12 -10
- package/src/core/principle-tree-ledger-adapter.ts +145 -0
- package/src/core/principle-tree-ledger.ts +8 -6
- package/src/core/reflection/reflection-context.ts +14 -109
- package/src/core/replay-engine.ts +8 -500
- package/src/core/rule-host-helpers.ts +5 -35
- package/src/core/rule-host-types.ts +10 -82
- package/src/core/rule-host.ts +6 -63
- package/src/core/runtime-v2-prompt-activation-reader.ts +231 -0
- package/src/core/session-tracker.ts +87 -101
- package/src/core/shadow-observation-registry.ts +19 -48
- package/src/core/trajectory.ts +3 -1
- package/src/core/workflow-funnel-loader.ts +62 -68
- package/src/core/workspace-context.ts +46 -0
- package/src/core/workspace-dir-service.ts +1 -1
- package/src/core/workspace-dir-validation.ts +18 -9
- package/src/hooks/AGENTS.md +1 -1
- package/src/hooks/gate-block-helper.ts +46 -44
- package/src/hooks/gate.ts +207 -7
- package/src/hooks/lifecycle.ts +30 -32
- package/src/hooks/llm.ts +60 -32
- package/src/hooks/pain.ts +297 -103
- package/src/hooks/prompt.ts +469 -339
- package/src/hooks/subagent.ts +2 -29
- package/src/i18n/commands.ts +2 -10
- package/src/index.ts +95 -85
- package/src/openclaw-sdk.ts +311 -0
- package/src/service/central-database.ts +8 -4
- package/src/service/evolution-queue-migration.ts +2 -1
- package/src/service/evolution-worker.ts +163 -1786
- package/src/service/internalization-trigger-adapter.ts +302 -0
- package/src/service/keyword-optimization-service.ts +4 -4
- package/src/service/monitoring-query-service.ts +1 -215
- package/src/service/queue-io.ts +60 -331
- package/src/service/runtime-summary-service.ts +115 -18
- package/src/service/subagent-workflow/index.ts +0 -41
- package/src/service/subagent-workflow/types.ts +9 -120
- package/src/service/subagent-workflow/workflow-store.ts +2 -119
- package/src/service/workflow-watchdog.ts +0 -43
- package/src/types/event-payload.ts +16 -74
- package/src/types/event-types.ts +39 -547
- package/src/types/hygiene-types.ts +7 -30
- package/src/types/principle-tree-schema.ts +20 -222
- package/src/types/queue.ts +15 -70
- package/src/types/runtime-summary.ts +5 -49
- package/src/utils/io.ts +10 -0
- package/src/utils/retry.ts +1 -1
- package/src/utils/shadow-fingerprint.ts +2 -2
- package/src/utils/workspace-resolver.ts +50 -0
- package/templates/langs/en/core/AGENTS.md +2 -2
- package/templates/langs/en/core/BOOT.md +1 -1
- package/templates/langs/en/core/HEARTBEAT.md +2 -2
- package/templates/langs/en/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
- package/templates/langs/en/skills/ai-sprint-orchestration/runtime/.gitignore +2 -2
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
- package/templates/langs/en/skills/evolve-task/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-cli-operator/SKILL.md +67 -0
- package/templates/langs/en/skills/pd-diagnostician/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-mentor/SKILL.md +1 -1
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +17 -39
- package/templates/langs/en/skills/pd-runtime-v2/SKILL.md +61 -0
- package/templates/langs/zh/core/AGENTS.md +2 -2
- package/templates/langs/zh/core/BOOT.md +1 -1
- package/templates/langs/zh/core/HEARTBEAT.md +2 -2
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/nocturnal-trinity-quality-enhancement.json +8 -8
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
- package/templates/langs/zh/skills/ai-sprint-orchestration/runtime/.gitignore +2 -2
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
- package/templates/langs/zh/skills/ai-sprint-orchestration/test/run.test.mjs +21 -5
- package/templates/langs/zh/skills/evolve-task/SKILL.md +2 -2
- package/templates/langs/zh/skills/pd-cli-operator/SKILL.md +67 -0
- package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-mentor/SKILL.md +1 -1
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +17 -38
- package/templates/langs/zh/skills/pd-runtime-v2/SKILL.md +61 -0
- package/tests/build-artifacts.test.ts +1 -3
- package/tests/commands/evolution-status.test.ts +0 -118
- package/tests/core/bootstrap-rules.test.ts +1 -1
- package/tests/core/config.test.ts +1 -1
- package/tests/core/event-log.test.ts +35 -0
- package/tests/core/evolution-engine.test.ts +610 -0
- package/tests/core/file-store.test.ts +102 -0
- package/tests/core/focus-history.test.ts +203 -11
- package/tests/core/merge-gate-audit.test.ts +2 -169
- package/tests/core/model-deployment-registry.test.ts +7 -1
- package/tests/core/model-training-registry.test.ts +19 -0
- package/tests/core/observability.test.ts +0 -1
- package/tests/core/pain-diagnostic-gate.test.ts +498 -0
- package/tests/core/pain.test.ts +0 -1
- package/tests/core/principle-internalization/deprecated-readiness.test.ts +2 -2
- package/tests/core/principle-internalization/lifecycle-metrics.test.ts +2 -2
- package/tests/core/principle-internalization/{internalization-routing-policy.test.ts → lifecycle-routing-policy.test.ts} +6 -6
- package/tests/core/principle-internalization/lineage-source-retired.test.ts +56 -0
- package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +1 -23
- package/tests/core/principle-tree-ledger-adapter.test.ts +253 -0
- package/tests/core/reflection-context.test.ts +0 -14
- package/tests/core/replay-engine.test.ts +127 -215
- package/tests/core/rule-host-helpers.test.ts +2 -2
- package/tests/core/rule-implementation-runtime.test.ts +0 -27
- package/tests/core/workflow-funnel-loader.test.ts +162 -0
- package/tests/core/workspace-dir-validation.test.ts +8 -1
- package/tests/core-anti-growth.test.ts +192 -0
- package/tests/hook-workspace-nextaction-contract.test.ts +42 -0
- package/tests/hooks/confirm-first-gate.test.ts +333 -0
- package/tests/hooks/gate-auto-correct-shadow.test.ts +310 -0
- package/tests/hooks/gate-auto-correct.test.ts +665 -0
- package/tests/hooks/gate-rule-host-pipeline.test.ts +2 -1
- package/tests/hooks/pain.test.ts +269 -12
- package/tests/hooks/prompt-characterization.test.ts +500 -0
- package/tests/hooks/prompt-size-guard.test.ts +329 -0
- package/tests/hooks/runtime-v2-prompt-activation.test.ts +869 -0
- package/tests/index.test.ts +94 -1
- package/tests/integration/auto-entry-gate.test.ts +248 -0
- package/tests/integration/internalization-trigger-guard.test.ts +69 -0
- package/tests/integration/m8-legacy-paths.test.ts +63 -0
- package/tests/integration/runtime-v2-pain-guard.test.ts +125 -0
- package/tests/plugin-config-resolution-cutover.test.ts +359 -0
- package/tests/runtime-v2-discovery-guard.test.ts +154 -0
- package/tests/service/central-database.test.ts +457 -0
- package/tests/service/evolution-worker.correction-observer.test.ts +173 -0
- package/tests/service/evolution-worker.timeout.test.ts +11 -129
- package/tests/service/internalization-trigger-adapter.test.ts +251 -0
- package/tests/service/monitoring-query-service.test.ts +1 -47
- package/tests/service/queue-io.test.ts +1 -62
- package/tests/service/runtime-summary-service.test.ts +184 -3
- package/tests/service/workflow-watchdog.test.ts +0 -91
- package/tests/utils/file-lock.test.ts +5 -3
- package/tests/utils/session-key.test.ts +52 -0
- package/tests/utils/subagent-probe.test.ts +48 -1
- package/vitest.config.ts +4 -11
- package/.planning/codebase/ARCHITECTURE.md +0 -157
- package/.planning/codebase/CONCERNS.md +0 -145
- package/.planning/codebase/CONVENTIONS.md +0 -148
- package/.planning/codebase/INTEGRATIONS.md +0 -81
- package/.planning/codebase/STACK.md +0 -87
- package/.planning/codebase/STRUCTURE.md +0 -193
- package/.planning/codebase/TESTING.md +0 -243
- package/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +0 -113
- package/docs/COMMAND_REFERENCE.md +0 -76
- package/docs/COMMAND_REFERENCE_EN.md +0 -79
- package/scripts/build-web.mjs +0 -46
- package/scripts/diagnose-nocturnal.mjs +0 -537
- package/scripts/seed-nocturnal-scenarios.mjs +0 -384
- package/src/commands/nocturnal-review.ts +0 -322
- package/src/commands/nocturnal-rollout.ts +0 -790
- package/src/commands/nocturnal-train.ts +0 -986
- package/src/commands/pd-reflect.ts +0 -88
- package/src/core/adaptive-thresholds.ts +0 -478
- package/src/core/diagnostician-task-store.ts +0 -192
- package/src/core/nocturnal-arbiter.ts +0 -715
- package/src/core/nocturnal-artifact-lineage.ts +0 -116
- package/src/core/nocturnal-artificer.ts +0 -257
- package/src/core/nocturnal-candidate-scoring.ts +0 -530
- package/src/core/nocturnal-compliance.ts +0 -1146
- package/src/core/nocturnal-dataset.ts +0 -763
- package/src/core/nocturnal-executability.ts +0 -428
- package/src/core/nocturnal-export.ts +0 -499
- package/src/core/nocturnal-paths.ts +0 -240
- package/src/core/nocturnal-reasoning-deriver.ts +0 -343
- package/src/core/nocturnal-rule-implementation-validator.ts +0 -246
- package/src/core/nocturnal-snapshot-contract.ts +0 -99
- package/src/core/nocturnal-trajectory-extractor.ts +0 -512
- package/src/core/nocturnal-trinity-types.ts +0 -218
- package/src/core/nocturnal-trinity.ts +0 -2680
- package/src/core/principle-internalization/deprecated-readiness.ts +0 -93
- package/src/core/principle-internalization/internalization-routing-policy.ts +0 -208
- package/src/core/principle-internalization/lifecycle-metrics.ts +0 -152
- package/src/http/principles-console-route.ts +0 -709
- package/src/service/central-health-service.ts +0 -49
- package/src/service/central-overview-service.ts +0 -138
- package/src/service/control-ui-query-service.ts +0 -900
- package/src/service/cooldown-strategy.ts +0 -97
- package/src/service/evolution-pain-context.ts +0 -79
- package/src/service/evolution-query-service.ts +0 -407
- package/src/service/health-query-service.ts +0 -1038
- package/src/service/nocturnal-config.ts +0 -214
- package/src/service/nocturnal-runtime.ts +0 -734
- package/src/service/nocturnal-service.ts +0 -1605
- package/src/service/nocturnal-target-selector.ts +0 -545
- package/src/service/sleep-cycle.ts +0 -157
- package/src/service/startup-reconciler.ts +0 -112
- package/src/service/subagent-workflow/correction-observer-types.ts +0 -82
- package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +0 -250
- package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +0 -1
- package/src/service/subagent-workflow/dynamic-timeout.ts +0 -30
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +0 -268
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -795
- package/src/service/subagent-workflow/runtime-direct-driver.ts +0 -268
- package/src/service/subagent-workflow/workflow-manager-base.ts +0 -580
- package/src/tools/write-pain-flag.ts +0 -215
- package/tests/commands/nocturnal-review.test.ts +0 -448
- package/tests/commands/nocturnal-train.test.ts +0 -97
- package/tests/commands/pd-reflect.test.ts +0 -49
- package/tests/core/adaptive-thresholds.test.ts +0 -261
- package/tests/core/nocturnal-arbiter.test.ts +0 -559
- package/tests/core/nocturnal-artifact-lineage.test.ts +0 -53
- package/tests/core/nocturnal-artificer.test.ts +0 -241
- package/tests/core/nocturnal-candidate-scoring.test.ts +0 -532
- package/tests/core/nocturnal-compliance-p-principles.test.ts +0 -133
- package/tests/core/nocturnal-compliance.test.ts +0 -646
- package/tests/core/nocturnal-dataset.test.ts +0 -892
- package/tests/core/nocturnal-e2e.test.ts +0 -234
- package/tests/core/nocturnal-executability.test.ts +0 -357
- package/tests/core/nocturnal-export.test.ts +0 -517
- package/tests/core/nocturnal-reasoning-deriver.test.ts +0 -372
- package/tests/core/nocturnal-reviewed-subset-comparison.test.ts +0 -428
- package/tests/core/nocturnal-rule-implementation-validator.test.ts +0 -127
- package/tests/core/nocturnal-snapshot-contract.test.ts +0 -121
- package/tests/core/nocturnal-trajectory-extractor.test.ts +0 -634
- package/tests/core/nocturnal-trinity.test.ts +0 -2053
- package/tests/core/pain-auto-repair.test.ts +0 -96
- package/tests/core/pain-integration.test.ts +0 -510
- package/tests/fixtures/nocturnal-reviewed-subset.json +0 -183
- package/tests/http/principles-console-route.test.ts +0 -162
- package/tests/integration/chaos-resilience.test.ts +0 -348
- package/tests/integration/empathy-workflow-integration.test.ts +0 -626
- package/tests/integration/pain-diagnostician-loop.e2e.test.ts +0 -380
- package/tests/service/control-ui-query-service.test.ts +0 -121
- package/tests/service/cooldown-strategy.test.ts +0 -164
- package/tests/service/data-endpoints-regression.test.ts +0 -834
- package/tests/service/empathy-observer-workflow-manager.test.ts +0 -175
- package/tests/service/evolution-worker.nocturnal.test.ts +0 -601
- package/tests/service/nocturnal-runtime-hardening.test.ts +0 -118
- package/tests/service/nocturnal-runtime.test.ts +0 -473
- package/tests/service/nocturnal-service-code-candidate.test.ts +0 -330
- package/tests/service/nocturnal-target-selector.test.ts +0 -615
- package/tests/service/startup-reconciler.test.ts +0 -148
- package/tests/tools/write-pain-flag.test.ts +0 -358
- package/ui/src/App.tsx +0 -45
- package/ui/src/api.ts +0 -220
- package/ui/src/charts.tsx +0 -955
- package/ui/src/components/ErrorState.tsx +0 -6
- package/ui/src/components/Loading.tsx +0 -13
- package/ui/src/components/ProtectedRoute.tsx +0 -12
- package/ui/src/components/Shell.tsx +0 -91
- package/ui/src/components/WorkspaceConfig.tsx +0 -178
- package/ui/src/components/index.ts +0 -5
- package/ui/src/context/auth.tsx +0 -80
- package/ui/src/context/theme.tsx +0 -66
- package/ui/src/hooks/useAutoRefresh.ts +0 -39
- package/ui/src/i18n/ui.ts +0 -473
- package/ui/src/main.tsx +0 -16
- package/ui/src/pages/EvolutionPage.tsx +0 -333
- package/ui/src/pages/FeedbackPage.tsx +0 -138
- package/ui/src/pages/GateMonitorPage.tsx +0 -136
- package/ui/src/pages/LoginPage.tsx +0 -89
- package/ui/src/pages/OverviewPage.tsx +0 -599
- package/ui/src/pages/SamplesPage.tsx +0 -174
- package/ui/src/pages/ThinkingModelsPage.tsx +0 -702
- package/ui/src/styles.css +0 -2020
- package/ui/src/types.ts +0 -384
- package/ui/src/utils/format.ts +0 -15
|
@@ -1,734 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nocturnal Runtime Service — Idle Detection Source of Truth
|
|
3
|
-
* ===========================================================
|
|
4
|
-
*
|
|
5
|
-
* This module is the authoritative source for workspace idle state used by the
|
|
6
|
-
* nocturnal reflection pipeline. It must NOT use `.last_active.json` as the primary
|
|
7
|
-
* source of truth.
|
|
8
|
-
*
|
|
9
|
-
* SOURCE OF TRUTH HIERARCHY (ordered by priority):
|
|
10
|
-
* 1. SessionState.lastActivityAt — via listSessions(workspaceDir)
|
|
11
|
-
* 2. trajectory timestamps — secondary guardrail only, NOT primary
|
|
12
|
-
* 3. nocturnal-runtime.json — cooldown/quota bookkeeping (ephemeral state)
|
|
13
|
-
*
|
|
14
|
-
* DESIGN CONSTRAINTS:
|
|
15
|
-
* - No `.last_active.json` as primary idle source
|
|
16
|
-
* - trajectory timestamps are a guardrail, not the primary source
|
|
17
|
-
* - cooldown/quota state is persisted in nocturnal-runtime.json
|
|
18
|
-
* - abandoned sessions (>2h inactive) must not block nocturnal flow
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import * as fs from 'fs';
|
|
22
|
-
import * as path from 'path';
|
|
23
|
-
import type { SessionState } from '../core/session-tracker.js';
|
|
24
|
-
import { listSessions } from '../core/session-tracker.js';
|
|
25
|
-
import { withLockAsync } from '../utils/file-lock.js';
|
|
26
|
-
import { atomicWriteFileSync } from '../utils/io.js';
|
|
27
|
-
import { DEFAULT_IDLE_THRESHOLD_MS, DEFAULT_QUOTA_WINDOW_MS } from '../config/defaults/runtime.js';
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// System Session Detection
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Returns true if the session was created by a system process (cron, boot, probe, subagent, acp).
|
|
35
|
-
* Uses OpenClaw's native session key patterns to avoid false positives.
|
|
36
|
-
*
|
|
37
|
-
* Detection priority (most reliable first):
|
|
38
|
-
* 1. trigger field: Most reliable — explicitly set by OpenClaw ("cron", "heartbeat", "subagent")
|
|
39
|
-
* 2. sessionKey patterns: Secondary confirmation via structured key (agent:main:cron:...)
|
|
40
|
-
* 3. sessionId prefix: Fallback for boot-, probe- prefixed IDs
|
|
41
|
-
*
|
|
42
|
-
* Excluded (NOT system sessions):
|
|
43
|
-
* - User sessions like agent:main:feishu:user:xxx — third component is channel type
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
function isSystemSession(state: SessionState): boolean {
|
|
47
|
-
const { sessionId, sessionKey, trigger } = state;
|
|
48
|
-
|
|
49
|
-
// Primary: trigger field is explicitly set by OpenClaw - most reliable
|
|
50
|
-
if (trigger === 'cron' || trigger === 'heartbeat' || trigger === 'subagent') {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Secondary: sessionKey pattern matching
|
|
55
|
-
if (sessionKey) {
|
|
56
|
-
const raw = sessionKey.toLowerCase();
|
|
57
|
-
if (raw.includes('cron:')) return true;
|
|
58
|
-
if (raw.includes('subagent:')) return true;
|
|
59
|
-
if (raw.includes('acp:')) return true;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Fallback: sessionId prefix patterns (boot-, probe-)
|
|
63
|
-
if (sessionId?.startsWith('boot-')) return true;
|
|
64
|
-
if (sessionId?.startsWith('probe-')) return true;
|
|
65
|
-
|
|
66
|
-
// CRITICAL FIX: Legacy sessions from persistence may have missing trigger/sessionKey
|
|
67
|
-
// If both are missing AND the session is old (inactive > abandoned threshold),
|
|
68
|
-
// treat as legacy/orphan to avoid blocking idle detection with unknown sessions.
|
|
69
|
-
// Recent sessions without trigger/sessionKey are likely real user sessions still
|
|
70
|
-
// being enriched — do NOT classify them as system sessions.
|
|
71
|
-
const ABANDONED_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
72
|
-
if (!trigger && !sessionKey) {
|
|
73
|
-
const inactiveFor = Date.now() - state.lastActivityAt;
|
|
74
|
-
if (inactiveFor > ABANDONED_THRESHOLD_MS) {
|
|
75
|
-
return true; // Legacy/orphan session — don't block idle detection
|
|
76
|
-
}
|
|
77
|
-
// Recent session without metadata — likely a real user session, let it through
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Constants
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/** File name for nocturnal runtime bookkeeping */
|
|
88
|
-
export const NOCTURNAL_RUNTIME_FILE = 'nocturnal-runtime.json';
|
|
89
|
-
|
|
90
|
-
/** Default cooldown between nocturnal runs (ms) */
|
|
91
|
-
export const DEFAULT_GLOBAL_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
|
92
|
-
|
|
93
|
-
/** Default per-principle cooldown (ms) */
|
|
94
|
-
export const DEFAULT_PRINCIPLE_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
95
|
-
|
|
96
|
-
/** Default maximum nocturnal runs per quota window */
|
|
97
|
-
export const DEFAULT_MAX_RUNS_PER_WINDOW = 3;
|
|
98
|
-
|
|
99
|
-
/** Abandoned session threshold: sessions inactive for longer than this are ignored (ms) */
|
|
100
|
-
export const DEFAULT_ABANDONED_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// Types
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
/** Per-task-kind failure tracking for cooldown escalation (Phase 40) */
|
|
107
|
-
export interface TaskFailureState {
|
|
108
|
-
/** Number of consecutive failures for this task kind */
|
|
109
|
-
consecutiveFailures: number;
|
|
110
|
-
/** Current escalation tier: 0=none, 1=30min, 2=4h, 3=24h (cap) */
|
|
111
|
-
escalationTier: number;
|
|
112
|
-
/** Cooldown deadline as ISO string, undefined if not in cooldown */
|
|
113
|
-
cooldownUntil?: string;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Persisted state for nocturnal runtime bookkeeping.
|
|
118
|
-
* Stored in {stateDir}/nocturnal-runtime.json
|
|
119
|
-
*/
|
|
120
|
-
export interface NocturnalRuntimeState {
|
|
121
|
-
/** Last time a nocturnal run was started (ISO string) */
|
|
122
|
-
lastRunAt?: string;
|
|
123
|
-
|
|
124
|
-
/** Last time a nocturnal run completed successfully */
|
|
125
|
-
lastSuccessfulRunAt?: string;
|
|
126
|
-
|
|
127
|
-
/** Cooldown end time for global cooldown (ISO string) */
|
|
128
|
-
globalCooldownUntil?: string;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Per-principle cooldown map.
|
|
132
|
-
* Key: principleId, Value: ISO string of cooldown end time
|
|
133
|
-
*/
|
|
134
|
-
principleCooldowns: Record<string, string>;
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Sliding window of recent run timestamps.
|
|
138
|
-
* Used for quota enforcement.
|
|
139
|
-
*/
|
|
140
|
-
recentRunTimestamps: string[];
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Sliding window of keyword optimization run timestamps.
|
|
144
|
-
* Separate from regular nocturnal runs to avoid quota pollution (fixes #321).
|
|
145
|
-
*/
|
|
146
|
-
keywordOptRunTimestamps: string[];
|
|
147
|
-
|
|
148
|
-
/** Metadata about last run (for debugging) */
|
|
149
|
-
lastRunMeta?: {
|
|
150
|
-
targetPrincipleId?: string;
|
|
151
|
-
sampleCount?: number;
|
|
152
|
-
status: 'success' | 'failed' | 'skipped';
|
|
153
|
-
reason?: string;
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Per-task-kind failure tracking for cooldown escalation.
|
|
158
|
-
* Key: taskKind string (e.g. 'sleep_reflection', 'keyword_optimization')
|
|
159
|
-
* Value: failure state with consecutive count, tier, and cooldown deadline
|
|
160
|
-
*/
|
|
161
|
-
taskFailureState?: Record<string, TaskFailureState>;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Result of an idle check */
|
|
165
|
-
export interface IdleCheckResult {
|
|
166
|
-
/** Whether the workspace is currently idle */
|
|
167
|
-
isIdle: boolean;
|
|
168
|
-
/** Most recent activity timestamp across all sessions (epoch ms) */
|
|
169
|
-
mostRecentActivityAt: number;
|
|
170
|
-
/** How long since the last activity (ms) */
|
|
171
|
-
idleForMs: number;
|
|
172
|
-
/** Number of active (non-abandoned) user sessions found */
|
|
173
|
-
userActiveSessions: number;
|
|
174
|
-
/** List of abandoned session IDs (inactive > abandoned threshold) */
|
|
175
|
-
abandonedSessionIds: string[];
|
|
176
|
-
/** Whether trajectory guardrail also confirms idle */
|
|
177
|
-
trajectoryGuardrailConfirmsIdle: boolean;
|
|
178
|
-
/** Reason for the idle determination */
|
|
179
|
-
reason: string;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Result of a cooldown check */
|
|
183
|
-
export interface CooldownCheckResult {
|
|
184
|
-
/** Whether the global cooldown is currently active */
|
|
185
|
-
globalCooldownActive: boolean;
|
|
186
|
-
/** When the global cooldown ends (ISO string), null if not in cooldown */
|
|
187
|
-
globalCooldownUntil: string | null;
|
|
188
|
-
/** Remaining ms until global cooldown expires */
|
|
189
|
-
globalCooldownRemainingMs: number;
|
|
190
|
-
/** Whether the principle-specific cooldown is active */
|
|
191
|
-
principleCooldownActive: boolean;
|
|
192
|
-
/** When the principle cooldown ends (ISO string), null if not in cooldown */
|
|
193
|
-
principleCooldownUntil: string | null;
|
|
194
|
-
/** Remaining ms until principle cooldown expires */
|
|
195
|
-
principleCooldownRemainingMs: number;
|
|
196
|
-
/** Whether the quota has been exhausted */
|
|
197
|
-
quotaExhausted: boolean;
|
|
198
|
-
/** Number of runs remaining in current window */
|
|
199
|
-
runsRemaining: number;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
// Default State
|
|
204
|
-
// ---------------------------------------------------------------------------
|
|
205
|
-
|
|
206
|
-
function createDefaultState(): NocturnalRuntimeState {
|
|
207
|
-
return {
|
|
208
|
-
principleCooldowns: {},
|
|
209
|
-
recentRunTimestamps: [],
|
|
210
|
-
keywordOptRunTimestamps: [],
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
// File Operations (with locking)
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
export async function readState(stateDir: string): Promise<NocturnalRuntimeState> {
|
|
219
|
-
const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
|
|
220
|
-
if (!fs.existsSync(filePath)) {
|
|
221
|
-
return createDefaultState();
|
|
222
|
-
}
|
|
223
|
-
try {
|
|
224
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
225
|
-
const parsed = JSON.parse(raw) as NocturnalRuntimeState;
|
|
226
|
-
// Ensure required fields exist (migration-safe)
|
|
227
|
-
return {
|
|
228
|
-
principleCooldowns: parsed.principleCooldowns ?? {},
|
|
229
|
-
recentRunTimestamps: parsed.recentRunTimestamps ?? [],
|
|
230
|
-
keywordOptRunTimestamps: parsed.keywordOptRunTimestamps ?? [],
|
|
231
|
-
lastRunAt: parsed.lastRunAt,
|
|
232
|
-
lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
|
|
233
|
-
globalCooldownUntil: parsed.globalCooldownUntil,
|
|
234
|
-
lastRunMeta: parsed.lastRunMeta,
|
|
235
|
-
taskFailureState: parsed.taskFailureState,
|
|
236
|
-
};
|
|
237
|
-
} catch {
|
|
238
|
-
// Corrupted file — start fresh
|
|
239
|
-
return createDefaultState();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function readStateSync(stateDir: string): NocturnalRuntimeState {
|
|
244
|
-
const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
|
|
245
|
-
if (!fs.existsSync(filePath)) {
|
|
246
|
-
return createDefaultState();
|
|
247
|
-
}
|
|
248
|
-
try {
|
|
249
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
250
|
-
const parsed = JSON.parse(raw) as NocturnalRuntimeState;
|
|
251
|
-
return {
|
|
252
|
-
principleCooldowns: parsed.principleCooldowns ?? {},
|
|
253
|
-
recentRunTimestamps: parsed.recentRunTimestamps ?? [],
|
|
254
|
-
keywordOptRunTimestamps: parsed.keywordOptRunTimestamps ?? [],
|
|
255
|
-
lastRunAt: parsed.lastRunAt,
|
|
256
|
-
lastSuccessfulRunAt: parsed.lastSuccessfulRunAt,
|
|
257
|
-
globalCooldownUntil: parsed.globalCooldownUntil,
|
|
258
|
-
lastRunMeta: parsed.lastRunMeta,
|
|
259
|
-
taskFailureState: parsed.taskFailureState,
|
|
260
|
-
};
|
|
261
|
-
} catch (err) {
|
|
262
|
-
console.warn(`[nocturnal-runtime] State file corrupted, resetting: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
-
return createDefaultState();
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export async function writeState(stateDir: string, state: NocturnalRuntimeState): Promise<void> {
|
|
268
|
-
const filePath = path.join(stateDir, NOCTURNAL_RUNTIME_FILE);
|
|
269
|
-
const stateDirPath = path.dirname(filePath);
|
|
270
|
-
if (!fs.existsSync(stateDirPath)) {
|
|
271
|
-
fs.mkdirSync(stateDirPath, { recursive: true });
|
|
272
|
-
}
|
|
273
|
-
await withLockAsync(filePath, async () => {
|
|
274
|
-
atomicWriteFileSync(filePath, JSON.stringify(state, null, 2));
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
// Idle Detection
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Check if the workspace is currently idle based on session activity.
|
|
284
|
-
*
|
|
285
|
-
* IDLE DETERMINATION LOGIC:
|
|
286
|
-
* - Collect all sessions for the workspace via listSessions()
|
|
287
|
-
* - Filter out abandoned sessions (inactive > abandonedThresholdMs)
|
|
288
|
-
* - Workspace is idle if: no active sessions OR all active sessions have lastActivityAt older than idleThresholdMs
|
|
289
|
-
* - Abandoned sessions do NOT contribute to idle determination
|
|
290
|
-
*
|
|
291
|
-
* @param workspaceDir - Workspace directory to check
|
|
292
|
-
* @param options.idleThresholdMs - Consider idle if no activity for this duration (default: 30 min)
|
|
293
|
-
* @param options.abandonedThresholdMs - Consider session abandoned if inactive for this duration (default: 2 hr)
|
|
294
|
-
* @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
|
|
295
|
-
* @returns IdleCheckResult with full diagnostic information
|
|
296
|
-
*/
|
|
297
|
-
|
|
298
|
-
export function checkWorkspaceIdle(
|
|
299
|
-
workspaceDir: string,
|
|
300
|
-
options: {
|
|
301
|
-
idleThresholdMs?: number;
|
|
302
|
-
abandonedThresholdMs?: number;
|
|
303
|
-
} = {},
|
|
304
|
-
trajectoryLastActivityAt?: number
|
|
305
|
-
): IdleCheckResult {
|
|
306
|
-
const {
|
|
307
|
-
idleThresholdMs = DEFAULT_IDLE_THRESHOLD_MS,
|
|
308
|
-
abandonedThresholdMs = DEFAULT_ABANDONED_THRESHOLD_MS,
|
|
309
|
-
} = options;
|
|
310
|
-
|
|
311
|
-
const now = Date.now();
|
|
312
|
-
const sessions = listSessions(workspaceDir);
|
|
313
|
-
|
|
314
|
-
// Separate active vs abandoned sessions
|
|
315
|
-
const abandonedSessions: string[] = [];
|
|
316
|
-
let mostRecentActivityAt = 0;
|
|
317
|
-
let userActiveSessions = 0;
|
|
318
|
-
|
|
319
|
-
for (const session of sessions) {
|
|
320
|
-
// Skip system sessions (cron, boot, probe, subagent, acp) from idle determination
|
|
321
|
-
if (isSystemSession(session)) continue;
|
|
322
|
-
|
|
323
|
-
const inactiveFor = now - session.lastActivityAt;
|
|
324
|
-
if (inactiveFor > abandonedThresholdMs) {
|
|
325
|
-
abandonedSessions.push(session.sessionId);
|
|
326
|
-
} else {
|
|
327
|
-
userActiveSessions++;
|
|
328
|
-
if (session.lastActivityAt > mostRecentActivityAt) {
|
|
329
|
-
mostRecentActivityAt = session.lastActivityAt;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const idleForMs = mostRecentActivityAt > 0 ? now - mostRecentActivityAt : now;
|
|
335
|
-
const isIdle = mostRecentActivityAt === 0 || idleForMs > idleThresholdMs;
|
|
336
|
-
|
|
337
|
-
// Trajectory guardrail: only used as a secondary check
|
|
338
|
-
// If trajectory says there's recent activity but session state says idle,
|
|
339
|
-
// that's a discrepancy we should note but still trust session state as primary
|
|
340
|
-
let trajectoryGuardrailConfirmsIdle = true;
|
|
341
|
-
if (trajectoryLastActivityAt !== undefined) {
|
|
342
|
-
const trajectoryIdleFor = now - trajectoryLastActivityAt;
|
|
343
|
-
// Guardrail confirms if trajectory also shows idle or near-idle (>80% of threshold)
|
|
344
|
-
trajectoryGuardrailConfirmsIdle = trajectoryIdleFor > idleThresholdMs * 0.8;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
let reason: string;
|
|
350
|
-
if (mostRecentActivityAt === 0) {
|
|
351
|
-
reason = 'No active sessions found — workspace is idle';
|
|
352
|
-
} else if (isIdle) {
|
|
353
|
-
reason = `Most recent activity ${idleForMs}ms ago (>${idleThresholdMs}ms threshold)`;
|
|
354
|
-
} else {
|
|
355
|
-
reason = `Recent activity ${idleForMs}ms ago (<${idleThresholdMs}ms threshold)`;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if (abandonedSessions.length > 0) {
|
|
359
|
-
reason += `; ${abandonedSessions.length} abandoned session(s) ignored`;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
isIdle,
|
|
364
|
-
mostRecentActivityAt,
|
|
365
|
-
idleForMs,
|
|
366
|
-
userActiveSessions,
|
|
367
|
-
abandonedSessionIds: abandonedSessions,
|
|
368
|
-
trajectoryGuardrailConfirmsIdle,
|
|
369
|
-
reason,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ---------------------------------------------------------------------------
|
|
374
|
-
// Cooldown Management
|
|
375
|
-
// ---------------------------------------------------------------------------
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Check if the workspace is currently in a cooldown period.
|
|
379
|
-
*
|
|
380
|
-
* @param stateDir - State directory
|
|
381
|
-
* @param principleId - Optional principle ID to check per-principle cooldown
|
|
382
|
-
* @param options - Cooldown configuration options
|
|
383
|
-
* @returns CooldownCheckResult
|
|
384
|
-
*/
|
|
385
|
-
|
|
386
|
-
export function checkCooldown(
|
|
387
|
-
stateDir: string,
|
|
388
|
-
principleId?: string,
|
|
389
|
-
options: {
|
|
390
|
-
globalCooldownMs?: number;
|
|
391
|
-
principleCooldownMs?: number;
|
|
392
|
-
maxRunsPerWindow?: number;
|
|
393
|
-
quotaWindowMs?: number;
|
|
394
|
-
} = {}
|
|
395
|
-
): CooldownCheckResult {
|
|
396
|
-
const {
|
|
397
|
-
maxRunsPerWindow = DEFAULT_MAX_RUNS_PER_WINDOW,
|
|
398
|
-
quotaWindowMs = DEFAULT_QUOTA_WINDOW_MS,
|
|
399
|
-
} = options;
|
|
400
|
-
|
|
401
|
-
const now = Date.now();
|
|
402
|
-
const state = readStateSync(stateDir);
|
|
403
|
-
|
|
404
|
-
// Global cooldown check
|
|
405
|
-
let globalCooldownActive = false;
|
|
406
|
-
let globalCooldownRemainingMs = 0;
|
|
407
|
-
let globalCooldownUntil: string | null = null;
|
|
408
|
-
|
|
409
|
-
if (state.globalCooldownUntil) {
|
|
410
|
-
const cooldownEnd = new Date(state.globalCooldownUntil).getTime();
|
|
411
|
-
if (cooldownEnd > now) {
|
|
412
|
-
globalCooldownActive = true;
|
|
413
|
-
globalCooldownRemainingMs = cooldownEnd - now;
|
|
414
|
-
|
|
415
|
-
globalCooldownUntil = state.globalCooldownUntil;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Principle-specific cooldown check
|
|
420
|
-
let principleCooldownActive = false;
|
|
421
|
-
let principleCooldownRemainingMs = 0;
|
|
422
|
-
let principleCooldownUntil: string | null = null;
|
|
423
|
-
|
|
424
|
-
if (principleId && state.principleCooldowns[principleId]) {
|
|
425
|
-
const cooldownEnd = new Date(state.principleCooldowns[principleId]).getTime();
|
|
426
|
-
if (cooldownEnd > now) {
|
|
427
|
-
principleCooldownActive = true;
|
|
428
|
-
principleCooldownRemainingMs = cooldownEnd - now;
|
|
429
|
-
principleCooldownUntil = state.principleCooldowns[principleId];
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Quota check: count runs in sliding window
|
|
434
|
-
const windowStart = now - quotaWindowMs;
|
|
435
|
-
const recentRuns = state.recentRunTimestamps
|
|
436
|
-
.map(ts => new Date(ts).getTime())
|
|
437
|
-
.filter(ts => ts > windowStart);
|
|
438
|
-
|
|
439
|
-
const quotaExhausted = recentRuns.length >= maxRunsPerWindow;
|
|
440
|
-
const runsRemaining = Math.max(0, maxRunsPerWindow - recentRuns.length);
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
globalCooldownActive,
|
|
444
|
-
globalCooldownUntil,
|
|
445
|
-
globalCooldownRemainingMs,
|
|
446
|
-
principleCooldownActive,
|
|
447
|
-
principleCooldownUntil,
|
|
448
|
-
principleCooldownRemainingMs,
|
|
449
|
-
quotaExhausted,
|
|
450
|
-
runsRemaining,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Check keyword optimization cooldown using its dedicated timestamp array.
|
|
456
|
-
* Fixes #321: keyword optimization quota must not be polluted by regular nocturnal runs.
|
|
457
|
-
*
|
|
458
|
-
* @param stateDir - State directory
|
|
459
|
-
* @param options - Quota options (default: 4 runs per 24 hours)
|
|
460
|
-
*/
|
|
461
|
-
export function checkKeywordOptCooldown(
|
|
462
|
-
stateDir: string,
|
|
463
|
-
options: {
|
|
464
|
-
maxRunsPerWindow?: number;
|
|
465
|
-
quotaWindowMs?: number;
|
|
466
|
-
} = {}
|
|
467
|
-
): CooldownCheckResult {
|
|
468
|
-
const {
|
|
469
|
-
maxRunsPerWindow = 4,
|
|
470
|
-
quotaWindowMs = 24 * 60 * 60 * 1000,
|
|
471
|
-
} = options;
|
|
472
|
-
|
|
473
|
-
const now = Date.now();
|
|
474
|
-
const state = readStateSync(stateDir);
|
|
475
|
-
|
|
476
|
-
const windowStart = now - quotaWindowMs;
|
|
477
|
-
const recentKeywordOpts: number[] = [];
|
|
478
|
-
for (const ts of state.keywordOptRunTimestamps) {
|
|
479
|
-
const parsed = new Date(ts).getTime();
|
|
480
|
-
if (Number.isNaN(parsed)) {
|
|
481
|
-
console.warn(`[NocturnalRuntime] Malformed timestamp in keywordOptRunTimestamps: "${ts}"`);
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
if (parsed > windowStart) {
|
|
485
|
-
recentKeywordOpts.push(parsed);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Keyword optimization uses a dedicated quota (keywordOptRunTimestamps)
|
|
490
|
-
// separate from the regular nocturnal run quota (runStartTimestamps).
|
|
491
|
-
// Global/principle cooldowns from regular nocturnal runs do NOT apply here.
|
|
492
|
-
return {
|
|
493
|
-
globalCooldownActive: false,
|
|
494
|
-
globalCooldownUntil: null,
|
|
495
|
-
globalCooldownRemainingMs: 0,
|
|
496
|
-
principleCooldownActive: false,
|
|
497
|
-
principleCooldownUntil: null,
|
|
498
|
-
principleCooldownRemainingMs: 0,
|
|
499
|
-
quotaExhausted: recentKeywordOpts.length >= maxRunsPerWindow,
|
|
500
|
-
runsRemaining: Math.max(0, maxRunsPerWindow - recentKeywordOpts.length),
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Record a keyword optimization run in its dedicated timestamp array.
|
|
506
|
-
* Does NOT affect regular nocturnal quota tracking.
|
|
507
|
-
*
|
|
508
|
-
* @param stateDir - State directory
|
|
509
|
-
* @param quotaWindowMs - Window size in ms (default: 24 hours)
|
|
510
|
-
*/
|
|
511
|
-
export async function recordKeywordOptRun(
|
|
512
|
-
stateDir: string,
|
|
513
|
-
quotaWindowMs: number = 24 * 60 * 60 * 1000
|
|
514
|
-
): Promise<void> {
|
|
515
|
-
const state = await readState(stateDir);
|
|
516
|
-
const now = new Date().toISOString();
|
|
517
|
-
|
|
518
|
-
state.keywordOptRunTimestamps.push(now);
|
|
519
|
-
|
|
520
|
-
const windowStart = Date.now() - quotaWindowMs;
|
|
521
|
-
state.keywordOptRunTimestamps = state.keywordOptRunTimestamps
|
|
522
|
-
.map(ts => new Date(ts).getTime())
|
|
523
|
-
.filter(ts => ts > windowStart)
|
|
524
|
-
.map(ts => new Date(ts).toISOString());
|
|
525
|
-
|
|
526
|
-
await writeState(stateDir, state);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Records a cooldown event for quota tracking (keyword_optimization etc.).
|
|
531
|
-
* Adds a timestamp to recentRunTimestamps and prunes entries outside the window.
|
|
532
|
-
* Does NOT set globalCooldownUntil — callers that need it should call recordRunStart.
|
|
533
|
-
*
|
|
534
|
-
* @param stateDir - State directory
|
|
535
|
-
* @param quotaWindowMs - Window size in ms (default: 24 hours)
|
|
536
|
-
*/
|
|
537
|
-
export async function recordCooldown(
|
|
538
|
-
stateDir: string,
|
|
539
|
-
quotaWindowMs: number = 24 * 60 * 60 * 1000
|
|
540
|
-
): Promise<void> {
|
|
541
|
-
const state = await readState(stateDir);
|
|
542
|
-
const now = new Date().toISOString();
|
|
543
|
-
|
|
544
|
-
state.recentRunTimestamps.push(now);
|
|
545
|
-
|
|
546
|
-
// Prune old timestamps outside the window
|
|
547
|
-
const windowStart = Date.now() - quotaWindowMs;
|
|
548
|
-
state.recentRunTimestamps = state.recentRunTimestamps
|
|
549
|
-
.map(ts => new Date(ts).getTime())
|
|
550
|
-
.filter(ts => ts > windowStart)
|
|
551
|
-
.map(ts => new Date(ts).toISOString());
|
|
552
|
-
|
|
553
|
-
await writeState(stateDir, state);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Record that a nocturnal run has started.
|
|
558
|
-
* Updates global cooldown and quota tracking.
|
|
559
|
-
*
|
|
560
|
-
* @param stateDir - State directory
|
|
561
|
-
* @param principleId - Target principle ID for this run
|
|
562
|
-
* @param cooldownMs - Global cooldown duration in ms (default: 1 hour)
|
|
563
|
-
*/
|
|
564
|
-
export async function recordRunStart(
|
|
565
|
-
stateDir: string,
|
|
566
|
-
principleId: string,
|
|
567
|
-
cooldownMs: number = DEFAULT_GLOBAL_COOLDOWN_MS
|
|
568
|
-
): Promise<void> {
|
|
569
|
-
const state = await readState(stateDir);
|
|
570
|
-
const now = new Date().toISOString();
|
|
571
|
-
|
|
572
|
-
state.lastRunAt = now;
|
|
573
|
-
state.lastRunMeta = {
|
|
574
|
-
targetPrincipleId: principleId,
|
|
575
|
-
status: 'skipped', // Will be updated on completion
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
// Set global cooldown (use configured value, not hardcoded default)
|
|
579
|
-
const cooldownUntil = new Date(Date.now() + cooldownMs).toISOString();
|
|
580
|
-
state.globalCooldownUntil = cooldownUntil;
|
|
581
|
-
|
|
582
|
-
// Add to recent runs for quota tracking
|
|
583
|
-
state.recentRunTimestamps.push(now);
|
|
584
|
-
|
|
585
|
-
// Prune old timestamps outside the quota window
|
|
586
|
-
const windowStart = Date.now() - DEFAULT_QUOTA_WINDOW_MS;
|
|
587
|
-
state.recentRunTimestamps = state.recentRunTimestamps
|
|
588
|
-
.map(ts => new Date(ts).getTime())
|
|
589
|
-
.filter(ts => ts > windowStart)
|
|
590
|
-
.map(ts => new Date(ts).toISOString());
|
|
591
|
-
|
|
592
|
-
await writeState(stateDir, state);
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* Record the outcome of a nocturnal run.
|
|
597
|
-
*
|
|
598
|
-
* @param stateDir - State directory
|
|
599
|
-
* @param outcome - 'success', 'failed', or 'skipped'
|
|
600
|
-
* @param details - Optional details about the run
|
|
601
|
-
*/
|
|
602
|
-
export async function recordRunEnd(
|
|
603
|
-
stateDir: string,
|
|
604
|
-
outcome: 'success' | 'failed' | 'skipped',
|
|
605
|
-
details?: {
|
|
606
|
-
sampleCount?: number;
|
|
607
|
-
reason?: string;
|
|
608
|
-
}
|
|
609
|
-
): Promise<void> {
|
|
610
|
-
const state = await readState(stateDir);
|
|
611
|
-
const now = new Date().toISOString();
|
|
612
|
-
|
|
613
|
-
if (outcome === 'success') {
|
|
614
|
-
state.lastSuccessfulRunAt = now;
|
|
615
|
-
|
|
616
|
-
// Also set per-principle cooldown if we know which principle was targeted
|
|
617
|
-
if (state.lastRunMeta?.targetPrincipleId) {
|
|
618
|
-
const pid = state.lastRunMeta.targetPrincipleId;
|
|
619
|
-
state.principleCooldowns[pid] = new Date(
|
|
620
|
-
Date.now() + DEFAULT_PRINCIPLE_COOLDOWN_MS
|
|
621
|
-
).toISOString();
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Update run metadata
|
|
626
|
-
state.lastRunMeta = {
|
|
627
|
-
...state.lastRunMeta,
|
|
628
|
-
status: outcome,
|
|
629
|
-
sampleCount: details?.sampleCount ?? state.lastRunMeta?.sampleCount,
|
|
630
|
-
reason: details?.reason ?? state.lastRunMeta?.reason,
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
// Note: global cooldown remains active (set at run start) - we don't clear it on failure
|
|
634
|
-
// This prevents rapid retry loops
|
|
635
|
-
|
|
636
|
-
await writeState(stateDir, state);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Clear all cooldowns (for testing or admin reset).
|
|
641
|
-
*
|
|
642
|
-
* @param stateDir - State directory
|
|
643
|
-
*/
|
|
644
|
-
export async function clearAllCooldowns(stateDir: string): Promise<void> {
|
|
645
|
-
const state = await readState(stateDir);
|
|
646
|
-
state.globalCooldownUntil = undefined;
|
|
647
|
-
state.principleCooldowns = {};
|
|
648
|
-
state.recentRunTimestamps = [];
|
|
649
|
-
state.keywordOptRunTimestamps = [];
|
|
650
|
-
state.lastRunMeta = undefined;
|
|
651
|
-
await writeState(stateDir, state);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Get the current runtime state (for debugging/inspection).
|
|
656
|
-
*
|
|
657
|
-
* @param stateDir - State directory
|
|
658
|
-
* @returns The current NocturnalRuntimeState
|
|
659
|
-
*/
|
|
660
|
-
export async function getRuntimeState(stateDir: string): Promise<NocturnalRuntimeState> {
|
|
661
|
-
return readState(stateDir);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// ---------------------------------------------------------------------------
|
|
665
|
-
// Convenience: Full Pre-Flight Check
|
|
666
|
-
// ---------------------------------------------------------------------------
|
|
667
|
-
|
|
668
|
-
export interface PreflightCheckResult {
|
|
669
|
-
canRun: boolean;
|
|
670
|
-
idle: IdleCheckResult;
|
|
671
|
-
cooldown: CooldownCheckResult;
|
|
672
|
-
/**
|
|
673
|
-
* Human-readable reasons why run is blocked (if canRun is false)
|
|
674
|
-
*/
|
|
675
|
-
blockers: string[];
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Combined pre-flight check for whether a nocturnal run should proceed.
|
|
680
|
-
* Integrates idle + cooldown + quota checks.
|
|
681
|
-
*
|
|
682
|
-
* @param workspaceDir - Workspace directory
|
|
683
|
-
* @param stateDir - State directory
|
|
684
|
-
* @param principleId - Target principle ID
|
|
685
|
-
* @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
|
|
686
|
-
* @param idleCheckOverride - Optional override for idle check result (for testing)
|
|
687
|
-
*/
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
export function checkPreflight(
|
|
691
|
-
workspaceDir: string,
|
|
692
|
-
stateDir: string,
|
|
693
|
-
principleId?: string,
|
|
694
|
-
trajectoryLastActivityAt?: number,
|
|
695
|
-
idleCheckOverride?: IdleCheckResult,
|
|
696
|
-
skipGatesForManualTrigger?: boolean
|
|
697
|
-
): PreflightCheckResult {
|
|
698
|
-
const idle = idleCheckOverride ?? checkWorkspaceIdle(workspaceDir, {}, trajectoryLastActivityAt);
|
|
699
|
-
const cooldown = checkCooldown(stateDir, principleId);
|
|
700
|
-
|
|
701
|
-
const blockers: string[] = [];
|
|
702
|
-
|
|
703
|
-
if (!idle.isIdle) {
|
|
704
|
-
blockers.push(`Workspace not idle (active for ${idle.idleForMs}ms, threshold=${DEFAULT_IDLE_THRESHOLD_MS}ms)`);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
if (!skipGatesForManualTrigger) {
|
|
708
|
-
if (cooldown.globalCooldownActive) {
|
|
709
|
-
blockers.push(`Global cooldown active until ${cooldown.globalCooldownUntil}`);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (cooldown.principleCooldownActive) {
|
|
713
|
-
blockers.push(`Principle cooldown active until ${cooldown.principleCooldownUntil}`);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
if (cooldown.quotaExhausted) {
|
|
717
|
-
blockers.push(`Quota exhausted (${DEFAULT_MAX_RUNS_PER_WINDOW} runs per ${DEFAULT_QUOTA_WINDOW_MS / 3600000}h window)`);
|
|
718
|
-
}
|
|
719
|
-
} else if (cooldown.globalCooldownActive || cooldown.principleCooldownActive || cooldown.quotaExhausted) {
|
|
720
|
-
// Log that gates are being bypassed for manual trigger
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
if (idle.abandonedSessionIds.length > 0 && idle.userActiveSessions === 0) {
|
|
724
|
-
// Only block if ALL sessions are abandoned (meaning workspace truly has no activity)
|
|
725
|
-
// If some sessions are active, we trust the session-based idle check
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
return {
|
|
729
|
-
canRun: blockers.length === 0,
|
|
730
|
-
idle,
|
|
731
|
-
cooldown,
|
|
732
|
-
blockers,
|
|
733
|
-
};
|
|
734
|
-
}
|