reflectt-node 0.1.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/LICENSE +178 -0
- package/README.md +188 -0
- package/dist/activationEvents.d.ts +110 -0
- package/dist/activationEvents.d.ts.map +1 -0
- package/dist/activationEvents.js +378 -0
- package/dist/activationEvents.js.map +1 -0
- package/dist/activity-signal.d.ts +30 -0
- package/dist/activity-signal.d.ts.map +1 -0
- package/dist/activity-signal.js +93 -0
- package/dist/activity-signal.js.map +1 -0
- package/dist/alert-integrity.d.ts +100 -0
- package/dist/alert-integrity.d.ts.map +1 -0
- package/dist/alert-integrity.js +333 -0
- package/dist/alert-integrity.js.map +1 -0
- package/dist/alert-preflight.d.ts +40 -0
- package/dist/alert-preflight.d.ts.map +1 -0
- package/dist/alert-preflight.js +235 -0
- package/dist/alert-preflight.js.map +1 -0
- package/dist/analytics.d.ts +131 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +371 -0
- package/dist/analytics.js.map +1 -0
- package/dist/artifact-mirror.d.ts +26 -0
- package/dist/artifact-mirror.d.ts.map +1 -0
- package/dist/artifact-mirror.js +170 -0
- package/dist/artifact-mirror.js.map +1 -0
- package/dist/artifact-resolver.d.ts +48 -0
- package/dist/artifact-resolver.d.ts.map +1 -0
- package/dist/artifact-resolver.js +164 -0
- package/dist/artifact-resolver.js.map +1 -0
- package/dist/assignment.d.ts +116 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +475 -0
- package/dist/assignment.js.map +1 -0
- package/dist/auditLedger.d.ts +50 -0
- package/dist/auditLedger.d.ts.map +1 -0
- package/dist/auditLedger.js +136 -0
- package/dist/auditLedger.js.map +1 -0
- package/dist/boardHealthWorker.d.ts +134 -0
- package/dist/boardHealthWorker.d.ts.map +1 -0
- package/dist/boardHealthWorker.js +882 -0
- package/dist/boardHealthWorker.js.map +1 -0
- package/dist/bootstrap-team.d.ts +42 -0
- package/dist/bootstrap-team.d.ts.map +1 -0
- package/dist/bootstrap-team.js +111 -0
- package/dist/bootstrap-team.js.map +1 -0
- package/dist/buildInfo.d.ts +17 -0
- package/dist/buildInfo.d.ts.map +1 -0
- package/dist/buildInfo.js +56 -0
- package/dist/buildInfo.js.map +1 -0
- package/dist/calendar-events.d.ts +133 -0
- package/dist/calendar-events.d.ts.map +1 -0
- package/dist/calendar-events.js +615 -0
- package/dist/calendar-events.js.map +1 -0
- package/dist/calendar-ical.d.ts +41 -0
- package/dist/calendar-ical.d.ts.map +1 -0
- package/dist/calendar-ical.js +413 -0
- package/dist/calendar-ical.js.map +1 -0
- package/dist/calendar-reminder-engine.d.ts +10 -0
- package/dist/calendar-reminder-engine.d.ts.map +1 -0
- package/dist/calendar-reminder-engine.js +143 -0
- package/dist/calendar-reminder-engine.js.map +1 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +391 -0
- package/dist/calendar.js.map +1 -0
- package/dist/canvas-multiplexer.d.ts +44 -0
- package/dist/canvas-multiplexer.d.ts.map +1 -0
- package/dist/canvas-multiplexer.js +150 -0
- package/dist/canvas-multiplexer.js.map +1 -0
- package/dist/canvas-slots.d.ts +83 -0
- package/dist/canvas-slots.d.ts.map +1 -0
- package/dist/canvas-slots.js +144 -0
- package/dist/canvas-slots.js.map +1 -0
- package/dist/canvas-types.d.ts +56 -0
- package/dist/canvas-types.d.ts.map +1 -0
- package/dist/canvas-types.js +54 -0
- package/dist/canvas-types.js.map +1 -0
- package/dist/cf-keepalive.d.ts +40 -0
- package/dist/cf-keepalive.d.ts.map +1 -0
- package/dist/cf-keepalive.js +153 -0
- package/dist/cf-keepalive.js.map +1 -0
- package/dist/changeFeed.d.ts +38 -0
- package/dist/changeFeed.d.ts.map +1 -0
- package/dist/changeFeed.js +324 -0
- package/dist/changeFeed.js.map +1 -0
- package/dist/channels.d.ts +28 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +23 -0
- package/dist/channels.js.map +1 -0
- package/dist/chat-approval-detector.d.ts +47 -0
- package/dist/chat-approval-detector.d.ts.map +1 -0
- package/dist/chat-approval-detector.js +224 -0
- package/dist/chat-approval-detector.js.map +1 -0
- package/dist/chat.d.ts +119 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +666 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1142 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud.d.ts +45 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +962 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +33 -0
- package/dist/config.js.map +1 -0
- package/dist/connectivity.d.ts +59 -0
- package/dist/connectivity.d.ts.map +1 -0
- package/dist/connectivity.js +173 -0
- package/dist/connectivity.js.map +1 -0
- package/dist/contacts.d.ts +59 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +183 -0
- package/dist/contacts.js.map +1 -0
- package/dist/content.d.ts +130 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +186 -0
- package/dist/content.js.map +1 -0
- package/dist/context-budget.d.ts +87 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +459 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/continuity-loop.d.ts +55 -0
- package/dist/continuity-loop.d.ts.map +1 -0
- package/dist/continuity-loop.js +267 -0
- package/dist/continuity-loop.js.map +1 -0
- package/dist/dashboard.d.ts +6 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +2348 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/db.d.ts +44 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +648 -0
- package/dist/db.js.map +1 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +159 -0
- package/dist/doctor.js.map +1 -0
- package/dist/duplicateClosureGuard.d.ts +31 -0
- package/dist/duplicateClosureGuard.d.ts.map +1 -0
- package/dist/duplicateClosureGuard.js +83 -0
- package/dist/duplicateClosureGuard.js.map +1 -0
- package/dist/embeddings.d.ts +13 -0
- package/dist/embeddings.d.ts.map +1 -0
- package/dist/embeddings.js +78 -0
- package/dist/embeddings.js.map +1 -0
- package/dist/escalation.d.ts +80 -0
- package/dist/escalation.d.ts.map +1 -0
- package/dist/escalation.js +213 -0
- package/dist/escalation.js.map +1 -0
- package/dist/events.d.ts +130 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +382 -0
- package/dist/events.js.map +1 -0
- package/dist/executionSweeper.d.ts +97 -0
- package/dist/executionSweeper.d.ts.map +1 -0
- package/dist/executionSweeper.js +875 -0
- package/dist/executionSweeper.js.map +1 -0
- package/dist/experiments.d.ts +47 -0
- package/dist/experiments.d.ts.map +1 -0
- package/dist/experiments.js +133 -0
- package/dist/experiments.js.map +1 -0
- package/dist/feedback.d.ts +179 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +397 -0
- package/dist/feedback.js.map +1 -0
- package/dist/files.d.ts +52 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +172 -0
- package/dist/files.js.map +1 -0
- package/dist/format-duration.d.ts +19 -0
- package/dist/format-duration.d.ts.map +1 -0
- package/dist/format-duration.js +33 -0
- package/dist/format-duration.js.map +1 -0
- package/dist/github-actor-auth.d.ts +20 -0
- package/dist/github-actor-auth.d.ts.map +1 -0
- package/dist/github-actor-auth.js +54 -0
- package/dist/github-actor-auth.js.map +1 -0
- package/dist/github-ci.d.ts +16 -0
- package/dist/github-ci.d.ts.map +1 -0
- package/dist/github-ci.js +37 -0
- package/dist/github-ci.js.map +1 -0
- package/dist/github-identity.d.ts +30 -0
- package/dist/github-identity.d.ts.map +1 -0
- package/dist/github-identity.js +96 -0
- package/dist/github-identity.js.map +1 -0
- package/dist/github-reviews.d.ts +24 -0
- package/dist/github-reviews.d.ts.map +1 -0
- package/dist/github-reviews.js +56 -0
- package/dist/github-reviews.js.map +1 -0
- package/dist/health.d.ts +391 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +1841 -0
- package/dist/health.js.map +1 -0
- package/dist/host-keepalive.d.ts +22 -0
- package/dist/host-keepalive.d.ts.map +1 -0
- package/dist/host-keepalive.js +126 -0
- package/dist/host-keepalive.js.map +1 -0
- package/dist/host-registry.d.ts +43 -0
- package/dist/host-registry.d.ts.map +1 -0
- package/dist/host-registry.js +93 -0
- package/dist/host-registry.js.map +1 -0
- package/dist/inbox.d.ts +87 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +410 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +306 -0
- package/dist/index.js.map +1 -0
- package/dist/insight-mutation.d.ts +32 -0
- package/dist/insight-mutation.d.ts.map +1 -0
- package/dist/insight-mutation.js +160 -0
- package/dist/insight-mutation.js.map +1 -0
- package/dist/insight-promotion.d.ts +89 -0
- package/dist/insight-promotion.d.ts.map +1 -0
- package/dist/insight-promotion.js +278 -0
- package/dist/insight-promotion.js.map +1 -0
- package/dist/insight-task-bridge.d.ts +77 -0
- package/dist/insight-task-bridge.d.ts.map +1 -0
- package/dist/insight-task-bridge.js +556 -0
- package/dist/insight-task-bridge.js.map +1 -0
- package/dist/insights.d.ts +222 -0
- package/dist/insights.d.ts.map +1 -0
- package/dist/insights.js +871 -0
- package/dist/insights.js.map +1 -0
- package/dist/intake-pipeline.d.ts +74 -0
- package/dist/intake-pipeline.d.ts.map +1 -0
- package/dist/intake-pipeline.js +199 -0
- package/dist/intake-pipeline.js.map +1 -0
- package/dist/intensity.d.ts +31 -0
- package/dist/intensity.d.ts.map +1 -0
- package/dist/intensity.js +94 -0
- package/dist/intensity.js.map +1 -0
- package/dist/knowledge-auto-index.d.ts +37 -0
- package/dist/knowledge-auto-index.d.ts.map +1 -0
- package/dist/knowledge-auto-index.js +149 -0
- package/dist/knowledge-auto-index.js.map +1 -0
- package/dist/knowledge-docs.d.ts +45 -0
- package/dist/knowledge-docs.d.ts.map +1 -0
- package/dist/knowledge-docs.js +188 -0
- package/dist/knowledge-docs.js.map +1 -0
- package/dist/lane-config.d.ts +25 -0
- package/dist/lane-config.d.ts.map +1 -0
- package/dist/lane-config.js +105 -0
- package/dist/lane-config.js.map +1 -0
- package/dist/lineage.d.ts +86 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +303 -0
- package/dist/lineage.js.map +1 -0
- package/dist/logStore.d.ts +25 -0
- package/dist/logStore.d.ts.map +1 -0
- package/dist/logStore.js +83 -0
- package/dist/logStore.js.map +1 -0
- package/dist/manage.d.ts +12 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +253 -0
- package/dist/manage.js.map +1 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +604 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +47 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +149 -0
- package/dist/memory.js.map +1 -0
- package/dist/mention-ack.d.ts +80 -0
- package/dist/mention-ack.d.ts.map +1 -0
- package/dist/mention-ack.js +175 -0
- package/dist/mention-ack.js.map +1 -0
- package/dist/messageRouter.d.ts +60 -0
- package/dist/messageRouter.d.ts.map +1 -0
- package/dist/messageRouter.js +309 -0
- package/dist/messageRouter.js.map +1 -0
- package/dist/mutationAlert.d.ts +44 -0
- package/dist/mutationAlert.d.ts.map +1 -0
- package/dist/mutationAlert.js +174 -0
- package/dist/mutationAlert.js.map +1 -0
- package/dist/noise-budget.d.ts +136 -0
- package/dist/noise-budget.d.ts.map +1 -0
- package/dist/noise-budget.js +340 -0
- package/dist/noise-budget.js.map +1 -0
- package/dist/notifications.d.ts +67 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +253 -0
- package/dist/notifications.js.map +1 -0
- package/dist/openclaw.d.ts +34 -0
- package/dist/openclaw.d.ts.map +1 -0
- package/dist/openclaw.js +208 -0
- package/dist/openclaw.js.map +1 -0
- package/dist/pause-controls.d.ts +31 -0
- package/dist/pause-controls.d.ts.map +1 -0
- package/dist/pause-controls.js +130 -0
- package/dist/pause-controls.js.map +1 -0
- package/dist/pidlock.d.ts +25 -0
- package/dist/pidlock.d.ts.map +1 -0
- package/dist/pidlock.js +179 -0
- package/dist/pidlock.js.map +1 -0
- package/dist/policy.d.ts +139 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +264 -0
- package/dist/policy.js.map +1 -0
- package/dist/polls.d.ts +47 -0
- package/dist/polls.d.ts.map +1 -0
- package/dist/polls.js +162 -0
- package/dist/polls.js.map +1 -0
- package/dist/portability.d.ts +55 -0
- package/dist/portability.d.ts.map +1 -0
- package/dist/portability.js +292 -0
- package/dist/portability.js.map +1 -0
- package/dist/pr-integrity.d.ts +45 -0
- package/dist/pr-integrity.d.ts.map +1 -0
- package/dist/pr-integrity.js +124 -0
- package/dist/pr-integrity.js.map +1 -0
- package/dist/prAutoMerge.d.ts +62 -0
- package/dist/prAutoMerge.d.ts.map +1 -0
- package/dist/prAutoMerge.js +493 -0
- package/dist/prAutoMerge.js.map +1 -0
- package/dist/preflight.d.ts +66 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +864 -0
- package/dist/preflight.js.map +1 -0
- package/dist/presence.d.ts +98 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +347 -0
- package/dist/presence.js.map +1 -0
- package/dist/provisioning.d.ts +101 -0
- package/dist/provisioning.d.ts.map +1 -0
- package/dist/provisioning.js +430 -0
- package/dist/provisioning.js.map +1 -0
- package/dist/reflection-automation.d.ts +59 -0
- package/dist/reflection-automation.d.ts.map +1 -0
- package/dist/reflection-automation.js +350 -0
- package/dist/reflection-automation.js.map +1 -0
- package/dist/reflections.d.ts +65 -0
- package/dist/reflections.d.ts.map +1 -0
- package/dist/reflections.js +306 -0
- package/dist/reflections.js.map +1 -0
- package/dist/release.d.ts +67 -0
- package/dist/release.d.ts.map +1 -0
- package/dist/release.js +275 -0
- package/dist/release.js.map +1 -0
- package/dist/request-tracker.d.ts +36 -0
- package/dist/request-tracker.d.ts.map +1 -0
- package/dist/request-tracker.js +109 -0
- package/dist/request-tracker.js.map +1 -0
- package/dist/research.d.ts +75 -0
- package/dist/research.d.ts.map +1 -0
- package/dist/research.js +171 -0
- package/dist/research.js.map +1 -0
- package/dist/routing-approvals.d.ts +73 -0
- package/dist/routing-approvals.d.ts.map +1 -0
- package/dist/routing-approvals.js +88 -0
- package/dist/routing-approvals.js.map +1 -0
- package/dist/routing-override.d.ts +94 -0
- package/dist/routing-override.d.ts.map +1 -0
- package/dist/routing-override.js +290 -0
- package/dist/routing-override.js.map +1 -0
- package/dist/scope-routing.d.ts +18 -0
- package/dist/scope-routing.d.ts.map +1 -0
- package/dist/scope-routing.js +29 -0
- package/dist/scope-routing.js.map +1 -0
- package/dist/secrets.d.ts +77 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +287 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +10887 -0
- package/dist/server.js.map +1 -0
- package/dist/service-probe.d.ts +53 -0
- package/dist/service-probe.d.ts.map +1 -0
- package/dist/service-probe.js +225 -0
- package/dist/service-probe.js.map +1 -0
- package/dist/shared-workspace-api.d.ts +73 -0
- package/dist/shared-workspace-api.d.ts.map +1 -0
- package/dist/shared-workspace-api.js +281 -0
- package/dist/shared-workspace-api.js.map +1 -0
- package/dist/shipped-heartbeat.d.ts +91 -0
- package/dist/shipped-heartbeat.d.ts.map +1 -0
- package/dist/shipped-heartbeat.js +272 -0
- package/dist/shipped-heartbeat.js.map +1 -0
- package/dist/starter-team.d.ts +23 -0
- package/dist/starter-team.d.ts.map +1 -0
- package/dist/starter-team.js +88 -0
- package/dist/starter-team.js.map +1 -0
- package/dist/suppression-ledger.d.ts +73 -0
- package/dist/suppression-ledger.d.ts.map +1 -0
- package/dist/suppression-ledger.js +125 -0
- package/dist/suppression-ledger.js.map +1 -0
- package/dist/system-loop-state.d.ts +4 -0
- package/dist/system-loop-state.d.ts.map +1 -0
- package/dist/system-loop-state.js +40 -0
- package/dist/system-loop-state.js.map +1 -0
- package/dist/taskCommentIngest.d.ts +43 -0
- package/dist/taskCommentIngest.d.ts.map +1 -0
- package/dist/taskCommentIngest.js +59 -0
- package/dist/taskCommentIngest.js.map +1 -0
- package/dist/taskPrecheck.d.ts +20 -0
- package/dist/taskPrecheck.d.ts.map +1 -0
- package/dist/taskPrecheck.js +329 -0
- package/dist/taskPrecheck.js.map +1 -0
- package/dist/taskStateSync.d.ts +8 -0
- package/dist/taskStateSync.d.ts.map +1 -0
- package/dist/taskStateSync.js +79 -0
- package/dist/taskStateSync.js.map +1 -0
- package/dist/tasks.d.ts +140 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +1281 -0
- package/dist/tasks.js.map +1 -0
- package/dist/team-config.d.ts +24 -0
- package/dist/team-config.d.ts.map +1 -0
- package/dist/team-config.js +221 -0
- package/dist/team-config.js.map +1 -0
- package/dist/team-doctor.d.ts +22 -0
- package/dist/team-doctor.d.ts.map +1 -0
- package/dist/team-doctor.js +270 -0
- package/dist/team-doctor.js.map +1 -0
- package/dist/team-pulse.d.ts +52 -0
- package/dist/team-pulse.d.ts.map +1 -0
- package/dist/team-pulse.js +176 -0
- package/dist/team-pulse.js.map +1 -0
- package/dist/telemetry.d.ts +74 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +256 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/test-task-filter.d.ts +21 -0
- package/dist/test-task-filter.d.ts.map +1 -0
- package/dist/test-task-filter.js +48 -0
- package/dist/test-task-filter.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-tracking.d.ts +101 -0
- package/dist/usage-tracking.d.ts.map +1 -0
- package/dist/usage-tracking.js +325 -0
- package/dist/usage-tracking.js.map +1 -0
- package/dist/vector-store.d.ts +87 -0
- package/dist/vector-store.d.ts.map +1 -0
- package/dist/vector-store.js +247 -0
- package/dist/vector-store.js.map +1 -0
- package/dist/watchdog/idleNudgeLane.d.ts +22 -0
- package/dist/watchdog/idleNudgeLane.d.ts.map +1 -0
- package/dist/watchdog/idleNudgeLane.js +98 -0
- package/dist/watchdog/idleNudgeLane.js.map +1 -0
- package/dist/webhooks.d.ts +103 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +398 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/working-contract.d.ts +42 -0
- package/dist/working-contract.d.ts.map +1 -0
- package/dist/working-contract.js +228 -0
- package/dist/working-contract.js.map +1 -0
- package/dist/ws-heartbeat.d.ts +66 -0
- package/dist/ws-heartbeat.d.ts.map +1 -0
- package/dist/ws-heartbeat.js +174 -0
- package/dist/ws-heartbeat.js.map +1 -0
- package/package.json +87 -0
- package/plugins/reflectt-channel/README.md +96 -0
- package/plugins/reflectt-channel/index.ts +789 -0
- package/plugins/reflectt-channel/openclaw.plugin.json +23 -0
- package/plugins/reflectt-channel/package.json +23 -0
- package/plugins/reflectt-channel/src/channel.ts +433 -0
- package/plugins/reflectt-channel/src/types.ts +29 -0
- package/public/avatars/echo.png +0 -0
- package/public/avatars/harmony.png +0 -0
- package/public/avatars/kai.png +0 -0
- package/public/avatars/link.png +0 -0
- package/public/avatars/pixel.png +0 -0
- package/public/avatars/rhythm.png +0 -0
- package/public/avatars/ryan.png +0 -0
- package/public/avatars/sage.png +0 -0
- package/public/avatars/scout.png +0 -0
- package/public/avatars/spark.png +0 -0
- package/public/dashboard-animations.css +381 -0
- package/public/dashboard.js +3479 -0
- package/public/docs.md +1062 -0
- package/public/file-upload-mock.html +1097 -0
- package/public/og-card.png +0 -0
- package/public/ui-kit.html +318 -0
- package/public/widget/feedback.js +194 -0
package/dist/health.js
ADDED
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) Reflectt AI
|
|
3
|
+
/**
|
|
4
|
+
* Team Health Monitoring
|
|
5
|
+
*
|
|
6
|
+
* Real-time team health diagnostics:
|
|
7
|
+
* - Silence detection (>3 heartbeats = ~45min)
|
|
8
|
+
* - Blocker tracking from messages
|
|
9
|
+
* - Overlapping work detection
|
|
10
|
+
* - Collaboration compliance (protocol v1)
|
|
11
|
+
*/
|
|
12
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { presenceManager } from './presence.js';
|
|
15
|
+
import { chatManager } from './chat.js';
|
|
16
|
+
import { taskManager } from './tasks.js';
|
|
17
|
+
import { routeMessage } from './messageRouter.js';
|
|
18
|
+
import { resolveIdleNudgeLane } from './watchdog/idleNudgeLane.js';
|
|
19
|
+
import { getDb } from './db.js';
|
|
20
|
+
import { policyManager } from './policy.js';
|
|
21
|
+
import { recordSystemLoopTick } from './system-loop-state.js';
|
|
22
|
+
/**
|
|
23
|
+
* Validate a task timestamp is within reasonable bounds.
|
|
24
|
+
* Rejects: 0, negative, NaN, future timestamps (>1h ahead), impossibly old (>1 year).
|
|
25
|
+
* Returns the validated timestamp or null if invalid.
|
|
26
|
+
*/
|
|
27
|
+
export function validateTaskTimestamp(ts, now) {
|
|
28
|
+
const n = Number(ts);
|
|
29
|
+
if (!n || !Number.isFinite(n) || n <= 0)
|
|
30
|
+
return null;
|
|
31
|
+
const currentTime = now ?? Date.now();
|
|
32
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
33
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
34
|
+
if (n > currentTime + ONE_HOUR_MS)
|
|
35
|
+
return null;
|
|
36
|
+
if (n < currentTime - ONE_YEAR_MS)
|
|
37
|
+
return null;
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Verify a task still exists and is in an active (non-deleted, non-done) state.
|
|
42
|
+
* Returns the fresh task if valid, null otherwise.
|
|
43
|
+
*/
|
|
44
|
+
export function verifyTaskExists(taskId) {
|
|
45
|
+
try {
|
|
46
|
+
const task = taskManager.getTask(taskId);
|
|
47
|
+
if (!task)
|
|
48
|
+
return null;
|
|
49
|
+
if (task.status === 'done')
|
|
50
|
+
return null;
|
|
51
|
+
return task;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compute per-agent active lane from task board + presence data.
|
|
59
|
+
* Priority: doing > blocked > validating > offline > queue-clear
|
|
60
|
+
*/
|
|
61
|
+
export function computeActiveLane(agentName, tasks, presenceStatus, lastSeenMs, offlineThresholdMs = 15 * 60 * 1000, now = Date.now()) {
|
|
62
|
+
const agent = agentName.toLowerCase();
|
|
63
|
+
const agentTasks = tasks.filter(t => (t.assignee || '').toLowerCase() === agent);
|
|
64
|
+
if (agentTasks.some(t => t.status === 'doing'))
|
|
65
|
+
return 'doing';
|
|
66
|
+
if (agentTasks.some(t => t.status === 'blocked'))
|
|
67
|
+
return 'blocked';
|
|
68
|
+
if (agentTasks.some(t => t.status === 'validating'))
|
|
69
|
+
return 'validating';
|
|
70
|
+
// Check if offline via presence
|
|
71
|
+
if (presenceStatus === 'offline')
|
|
72
|
+
return 'offline';
|
|
73
|
+
if (lastSeenMs !== undefined && lastSeenMs > 0 && (now - lastSeenMs) >= offlineThresholdMs)
|
|
74
|
+
return 'offline';
|
|
75
|
+
return 'queue-clear';
|
|
76
|
+
}
|
|
77
|
+
class TeamHealthMonitor {
|
|
78
|
+
blockerKeywords = [
|
|
79
|
+
'blocked',
|
|
80
|
+
'blocker',
|
|
81
|
+
'waiting on',
|
|
82
|
+
'waiting for',
|
|
83
|
+
'need help',
|
|
84
|
+
'stuck',
|
|
85
|
+
'can\'t',
|
|
86
|
+
'unable to',
|
|
87
|
+
'no access',
|
|
88
|
+
'missing',
|
|
89
|
+
];
|
|
90
|
+
healthHistory = [];
|
|
91
|
+
MAX_HISTORY = 168; // 7 days at hourly snapshots
|
|
92
|
+
lastSnapshotTime = 0;
|
|
93
|
+
SNAPSHOT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
94
|
+
trioAgents = ['kai', 'link', 'pixel'];
|
|
95
|
+
workerAgents = ['link', 'pixel'];
|
|
96
|
+
workerCadenceMaxMin = 45;
|
|
97
|
+
leadCadenceMaxMin = 60;
|
|
98
|
+
blockedEscalationMin = 20;
|
|
99
|
+
trioSilenceMaxMin = 60;
|
|
100
|
+
// System idle nudge settings (configurable via env)
|
|
101
|
+
idleNudgeEnabled = process.env.IDLE_NUDGE_ENABLED !== 'false';
|
|
102
|
+
idleNudgeWarnMin = Number(process.env.IDLE_NUDGE_WARN_MIN || 45);
|
|
103
|
+
idleNudgeEscalateMin = Number(process.env.IDLE_NUDGE_ESCALATE_MIN || 60);
|
|
104
|
+
idleNudgeCooldownMin = Number(process.env.IDLE_NUDGE_COOLDOWN_MIN || 20);
|
|
105
|
+
idleNudgeSuppressRecentMin = Number(process.env.IDLE_NUDGE_SUPPRESS_RECENT_MIN || 20);
|
|
106
|
+
idleNudgeShipCooldownMin = Number(process.env.IDLE_NUDGE_SHIP_COOLDOWN_MIN || 30);
|
|
107
|
+
idleNudgeActiveTaskMaxAgeMin = Number(process.env.IDLE_NUDGE_ACTIVE_TASK_MAX_AGE_MIN || 180);
|
|
108
|
+
idleNudgeExcluded = new Set((process.env.IDLE_NUDGE_EXCLUDE || 'ryan,diag')
|
|
109
|
+
.split(',')
|
|
110
|
+
.map(s => s.trim().toLowerCase())
|
|
111
|
+
.filter(Boolean));
|
|
112
|
+
idleNudgeState = new Map();
|
|
113
|
+
idleNudgeLastDecisions = [];
|
|
114
|
+
cadenceWatchdogEnabled = process.env.CADENCE_WATCHDOG_ENABLED !== 'false';
|
|
115
|
+
cadenceSilenceMin = Number(process.env.CADENCE_SILENCE_MIN || 60);
|
|
116
|
+
cadenceWorkingStaleMin = Number(process.env.CADENCE_WORKING_STALE_MIN || 45);
|
|
117
|
+
cadenceWorkingTaskMaxAgeMin = Number(process.env.CADENCE_WORKING_TASK_MAX_AGE_MIN || 240);
|
|
118
|
+
cadenceAlertCooldownMin = Number(process.env.CADENCE_ALERT_COOLDOWN_MIN || 30);
|
|
119
|
+
/** Grace period after service start — suppress cadence alerts to avoid restart-triggered batch spam */
|
|
120
|
+
cadenceStartupGraceMs = Number(process.env.CADENCE_STARTUP_GRACE_MS || 10 * 60_000);
|
|
121
|
+
/** Window for rate-limit failure message suppression (ms) */
|
|
122
|
+
cadenceRateLimitSuppressMs = Number(process.env.CADENCE_RATE_LIMIT_SUPPRESS_MS || 20 * 60_000);
|
|
123
|
+
cadenceAlertState = new Map();
|
|
124
|
+
staleDoingThresholdMin = Number(process.env.STALE_DOING_THRESHOLD_MIN || 240);
|
|
125
|
+
// Mention rescue fallback: if Ryan pings trio and nobody replies quickly, emit a direct system ack.
|
|
126
|
+
mentionRescueEnabled = process.env.MENTION_RESCUE_ENABLED !== 'false';
|
|
127
|
+
// Default delay is intentionally non-zero to avoid noisy immediate fallback nudges.
|
|
128
|
+
// We also clamp to a minimum to prevent misconfig (e.g. env="0") from spamming #general.
|
|
129
|
+
// Override with MENTION_RESCUE_DELAY_MIN to increase (values <3 are treated as 3).
|
|
130
|
+
mentionRescueDelayMin = (() => {
|
|
131
|
+
const raw = process.env.MENTION_RESCUE_DELAY_MIN;
|
|
132
|
+
const parsed = (raw === undefined || raw.trim() === '') ? 5 : Number(raw);
|
|
133
|
+
const val = Number.isFinite(parsed) ? parsed : 5;
|
|
134
|
+
return Math.max(3, val);
|
|
135
|
+
})();
|
|
136
|
+
mentionRescueCooldownMin = Number(process.env.MENTION_RESCUE_COOLDOWN_MIN || 10);
|
|
137
|
+
mentionRescueGlobalCooldownMin = Number(process.env.MENTION_RESCUE_GLOBAL_COOLDOWN_MIN || 5);
|
|
138
|
+
/** Maps mentionId → { lastRescueAt, rescueCount }. Once rescued, won't rescue again (one-shot). */
|
|
139
|
+
mentionRescueState = new Map();
|
|
140
|
+
mentionRescueLastAt = 0;
|
|
141
|
+
/** Thread-level idempotency: tracks which thread keys have been rescued (persisted in SQLite). */
|
|
142
|
+
mentionRescueDbInitialized = false;
|
|
143
|
+
systemStartTime = Date.now();
|
|
144
|
+
requestCount = 0;
|
|
145
|
+
errorCount = 0;
|
|
146
|
+
requestTimes = [];
|
|
147
|
+
MAX_REQUEST_TIMES = 1000;
|
|
148
|
+
/**
|
|
149
|
+
* Get comprehensive team health snapshot
|
|
150
|
+
*/
|
|
151
|
+
async getHealth() {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const agents = await this.getAgentHealthStatuses(now);
|
|
154
|
+
const blockers = await this.extractBlockers();
|
|
155
|
+
const overlaps = await this.detectOverlaps();
|
|
156
|
+
const compliance = await this.getCollaborationCompliance(now);
|
|
157
|
+
const staleDoing = this.getStaleDoingSnapshot(now);
|
|
158
|
+
const silentAgents = agents
|
|
159
|
+
.filter(a => a.status === 'silent')
|
|
160
|
+
.map(a => a.agent);
|
|
161
|
+
const activeAgents = agents
|
|
162
|
+
.filter(a => a.status === 'active')
|
|
163
|
+
.map(a => a.agent);
|
|
164
|
+
return {
|
|
165
|
+
timestamp: now,
|
|
166
|
+
agents,
|
|
167
|
+
blockers,
|
|
168
|
+
overlaps,
|
|
169
|
+
silentAgents,
|
|
170
|
+
activeAgents,
|
|
171
|
+
compliance,
|
|
172
|
+
staleDoing,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
getTaskLastActivityAt(taskId, fallbackUpdatedAt) {
|
|
176
|
+
const comments = taskManager.getTaskComments(taskId);
|
|
177
|
+
const latestCommentAt = comments.reduce((max, c) => Math.max(max, this.parseTimestamp(c.timestamp)), 0);
|
|
178
|
+
return Math.max(fallbackUpdatedAt, latestCommentAt);
|
|
179
|
+
}
|
|
180
|
+
extractTaskPrLink(task) {
|
|
181
|
+
if (!task?.metadata || typeof task.metadata !== 'object')
|
|
182
|
+
return null;
|
|
183
|
+
const metadata = task.metadata;
|
|
184
|
+
const candidates = [];
|
|
185
|
+
const directPrUrl = metadata.pr_url;
|
|
186
|
+
const directPrLink = metadata.pr_link;
|
|
187
|
+
if (typeof directPrUrl === 'string')
|
|
188
|
+
candidates.push(directPrUrl);
|
|
189
|
+
if (typeof directPrLink === 'string')
|
|
190
|
+
candidates.push(directPrLink);
|
|
191
|
+
const artifacts = metadata.artifacts;
|
|
192
|
+
if (Array.isArray(artifacts)) {
|
|
193
|
+
for (const item of artifacts) {
|
|
194
|
+
if (typeof item === 'string')
|
|
195
|
+
candidates.push(item);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const qaBundle = metadata.qa_bundle;
|
|
199
|
+
if (qaBundle && typeof qaBundle === 'object') {
|
|
200
|
+
const artifactLinks = qaBundle.artifact_links;
|
|
201
|
+
if (Array.isArray(artifactLinks)) {
|
|
202
|
+
for (const item of artifactLinks) {
|
|
203
|
+
if (typeof item === 'string')
|
|
204
|
+
candidates.push(item);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const pullUrlRegex = /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+(?:[^\s]*)?/i;
|
|
209
|
+
for (const candidate of candidates) {
|
|
210
|
+
const trimmed = candidate.trim();
|
|
211
|
+
if (!trimmed)
|
|
212
|
+
continue;
|
|
213
|
+
const match = trimmed.match(pullUrlRegex);
|
|
214
|
+
if (match)
|
|
215
|
+
return match[0];
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
getStaleDoingSnapshot(now = Date.now()) {
|
|
220
|
+
const doing = taskManager.listTasks({ status: 'doing' });
|
|
221
|
+
const MAX_STALE_DISPLAY_MIN = 24 * 60;
|
|
222
|
+
const staleTasks = doing
|
|
223
|
+
.filter(task => Boolean(task.assignee))
|
|
224
|
+
// Verify task still exists (guards against stale cache / deleted tasks)
|
|
225
|
+
.filter(task => verifyTaskExists(task.id) !== null)
|
|
226
|
+
.map((task) => {
|
|
227
|
+
const lastActivityAt = this.getTaskLastActivityAt(task.id, this.parseTimestamp(task.updatedAt));
|
|
228
|
+
// Validate timestamp bounds — cap impossible ages
|
|
229
|
+
const validatedAt = validateTaskTimestamp(lastActivityAt, now);
|
|
230
|
+
const staleMinutes = validatedAt
|
|
231
|
+
? Math.min(Math.max(0, Math.floor((now - validatedAt) / 60_000)), MAX_STALE_DISPLAY_MIN)
|
|
232
|
+
: MAX_STALE_DISPLAY_MIN;
|
|
233
|
+
return {
|
|
234
|
+
task_id: task.id,
|
|
235
|
+
assignee: task.assignee || 'unassigned',
|
|
236
|
+
title: task.title,
|
|
237
|
+
stale_minutes: staleMinutes,
|
|
238
|
+
last_activity_at: lastActivityAt,
|
|
239
|
+
};
|
|
240
|
+
})
|
|
241
|
+
.filter(task => task.stale_minutes >= this.staleDoingThresholdMin)
|
|
242
|
+
.sort((a, b) => b.stale_minutes - a.stale_minutes);
|
|
243
|
+
return {
|
|
244
|
+
thresholdMinutes: this.staleDoingThresholdMin,
|
|
245
|
+
count: staleTasks.length,
|
|
246
|
+
tasks: staleTasks,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async getAgentHealthSummary(now = Date.now()) {
|
|
250
|
+
const agents = await this.getAgentHealthStatuses(now);
|
|
251
|
+
const allTasks = taskManager.listTasks({});
|
|
252
|
+
const healthyMaxMs = 45 * 60 * 1000;
|
|
253
|
+
const stuckMinMs = 60 * 60 * 1000;
|
|
254
|
+
const offlineMinMs = 120 * 60 * 1000;
|
|
255
|
+
const rows = agents.map((agent) => {
|
|
256
|
+
const heartbeatAgeMs = Math.max(0, agent.minutesSinceLastSeen) * 60_000;
|
|
257
|
+
let state = 'healthy';
|
|
258
|
+
let staleReason = null;
|
|
259
|
+
if (agent.lastSeen <= 0 || heartbeatAgeMs >= offlineMinMs) {
|
|
260
|
+
state = 'offline';
|
|
261
|
+
staleReason = 'offline-no-heartbeat';
|
|
262
|
+
}
|
|
263
|
+
else if (agent.idleWithActiveTask && heartbeatAgeMs >= stuckMinMs) {
|
|
264
|
+
state = 'stuck';
|
|
265
|
+
staleReason = 'active-task-idle-over-60m';
|
|
266
|
+
}
|
|
267
|
+
else if (heartbeatAgeMs > healthyMaxMs) {
|
|
268
|
+
state = 'idle';
|
|
269
|
+
staleReason = 'heartbeat-age-over-45m';
|
|
270
|
+
}
|
|
271
|
+
const presenceStatus = state === 'offline' ? 'offline' : undefined;
|
|
272
|
+
const activeLane = computeActiveLane(agent.agent, allTasks, presenceStatus, agent.lastSeen, offlineMinMs, now);
|
|
273
|
+
return {
|
|
274
|
+
agent: agent.agent,
|
|
275
|
+
last_seen: agent.lastSeen,
|
|
276
|
+
active_task: agent.currentTask || null,
|
|
277
|
+
heartbeat_age_ms: heartbeatAgeMs,
|
|
278
|
+
last_shipped_at: agent.lastProductiveAt,
|
|
279
|
+
shipped_age_ms: agent.minutesSinceProductive === null ? null : Math.max(0, agent.minutesSinceProductive) * 60_000,
|
|
280
|
+
stale_reason: staleReason,
|
|
281
|
+
idle_with_active_task: agent.idleWithActiveTask,
|
|
282
|
+
state,
|
|
283
|
+
active_lane: activeLane,
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
agents: rows,
|
|
288
|
+
thresholds: {
|
|
289
|
+
healthyMaxMs,
|
|
290
|
+
stuckMinMs,
|
|
291
|
+
offlineMinMs,
|
|
292
|
+
},
|
|
293
|
+
timestamp: now,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async getCollaborationCompliance(now = Date.now()) {
|
|
297
|
+
const tasks = taskManager.listTasks({});
|
|
298
|
+
const messages = chatManager.getMessages({ limit: 300 });
|
|
299
|
+
const incidents = await this.getComplianceIncidents(now, messages);
|
|
300
|
+
const complianceAgents = this.trioAgents.map((agent) => {
|
|
301
|
+
const expectedCadenceMin = agent === 'kai' ? this.leadCadenceMaxMin : this.workerCadenceMaxMin;
|
|
302
|
+
const lastValidStatusAt = this.findLastValidStatusAt(messages, agent);
|
|
303
|
+
const lastValidStatusAgeMin = lastValidStatusAt
|
|
304
|
+
? Math.floor((now - lastValidStatusAt) / 1000 / 60)
|
|
305
|
+
: 9999;
|
|
306
|
+
let state = 'ok';
|
|
307
|
+
if (lastValidStatusAgeMin > expectedCadenceMin) {
|
|
308
|
+
state = 'violation';
|
|
309
|
+
}
|
|
310
|
+
else if (lastValidStatusAgeMin >= Math.max(0, expectedCadenceMin - 10)) {
|
|
311
|
+
state = 'warning';
|
|
312
|
+
}
|
|
313
|
+
const hasEscalation = incidents.some(i => i.agent === agent);
|
|
314
|
+
if (hasEscalation) {
|
|
315
|
+
state = 'escalated';
|
|
316
|
+
}
|
|
317
|
+
const activeTask = tasks.find(t => t.assignee === agent && t.status === 'doing');
|
|
318
|
+
return {
|
|
319
|
+
agent,
|
|
320
|
+
taskId: activeTask?.id || null,
|
|
321
|
+
lastValidStatusAt,
|
|
322
|
+
lastValidStatusAgeMin,
|
|
323
|
+
expectedCadenceMin,
|
|
324
|
+
state,
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
const workerWorstAgeMin = Math.max(...complianceAgents
|
|
328
|
+
.filter(a => this.workerAgents.includes(a.agent))
|
|
329
|
+
.map(a => a.lastValidStatusAgeMin), 0);
|
|
330
|
+
const leadAgeMin = complianceAgents.find(a => a.agent === 'kai')?.lastValidStatusAgeMin ?? 9999;
|
|
331
|
+
const blockerMessages = messages.filter(m => typeof m.content === 'string'
|
|
332
|
+
&& /\bblocker\s*:\s*(?!none|no|n\/a|na\b).+/i.test(m.content)
|
|
333
|
+
&& this.trioAgents.includes((m.from || '').toLowerCase()));
|
|
334
|
+
const oldestBlockerMin = blockerMessages.length > 0
|
|
335
|
+
? Math.max(...blockerMessages.map(m => Math.floor((now - (m.timestamp || now)) / 1000 / 60)))
|
|
336
|
+
: 0;
|
|
337
|
+
const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
|
|
338
|
+
const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 1000 / 60);
|
|
339
|
+
return {
|
|
340
|
+
summary: {
|
|
341
|
+
workerCadenceMaxMin: this.workerCadenceMaxMin,
|
|
342
|
+
leadCadenceMaxMin: this.leadCadenceMaxMin,
|
|
343
|
+
blockedEscalationMin: this.blockedEscalationMin,
|
|
344
|
+
trioSilenceMaxMin: this.trioSilenceMaxMin,
|
|
345
|
+
workerWorstAgeMin,
|
|
346
|
+
leadAgeMin,
|
|
347
|
+
oldestBlockerMin,
|
|
348
|
+
trioSilenceMin,
|
|
349
|
+
},
|
|
350
|
+
agents: complianceAgents,
|
|
351
|
+
incidents,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
parseTimestamp(value) {
|
|
355
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
356
|
+
return value;
|
|
357
|
+
if (typeof value === 'string') {
|
|
358
|
+
const asNum = Number(value);
|
|
359
|
+
if (Number.isFinite(asNum) && asNum > 0)
|
|
360
|
+
return asNum;
|
|
361
|
+
const asDate = Date.parse(value);
|
|
362
|
+
if (Number.isFinite(asDate) && asDate > 0)
|
|
363
|
+
return asDate;
|
|
364
|
+
}
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
getLatestGeneralMessageAt(messages, author) {
|
|
368
|
+
let lastAt = 0;
|
|
369
|
+
for (const m of messages) {
|
|
370
|
+
if ((m.from || '').toLowerCase() !== author)
|
|
371
|
+
continue;
|
|
372
|
+
if ((m.channel || 'general') !== 'general')
|
|
373
|
+
continue;
|
|
374
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
375
|
+
if (ts > lastAt)
|
|
376
|
+
lastAt = ts;
|
|
377
|
+
}
|
|
378
|
+
return lastAt;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Get the latest message timestamp from an agent across ALL channels.
|
|
382
|
+
* Used for activity suppression — if an agent is posting anywhere, they're not idle.
|
|
383
|
+
*/
|
|
384
|
+
getLatestAnyMessageAt(messages, author) {
|
|
385
|
+
let lastAt = 0;
|
|
386
|
+
for (const m of messages) {
|
|
387
|
+
if ((m.from || '').toLowerCase() !== author)
|
|
388
|
+
continue;
|
|
389
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
390
|
+
if (ts > lastAt)
|
|
391
|
+
lastAt = ts;
|
|
392
|
+
}
|
|
393
|
+
return lastAt;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Returns true if the agent has posted a rate-limit / provider-failure message
|
|
397
|
+
* within the given window. Used to suppress false cadence alerts during outages.
|
|
398
|
+
*
|
|
399
|
+
* These messages look like: "⚠️ Agent failed before reply: All models failed..."
|
|
400
|
+
* They indicate the agent is alive and trying but blocked by infra — not genuinely idle.
|
|
401
|
+
*/
|
|
402
|
+
hasRecentRateLimitFailure(messages, agent, now, windowMs) {
|
|
403
|
+
const cutoff = now - windowMs;
|
|
404
|
+
return messages.some((m) => {
|
|
405
|
+
if ((m.from || '').toLowerCase() !== agent)
|
|
406
|
+
return false;
|
|
407
|
+
const ts = Number(m.timestamp || 0);
|
|
408
|
+
if (ts < cutoff)
|
|
409
|
+
return false;
|
|
410
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
411
|
+
return content.startsWith('⚠️ Agent failed before reply:') || content.startsWith('⚠️ API rate limit reached');
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
findLastValidStatusAt(messages, agent) {
|
|
415
|
+
let lastAt = null;
|
|
416
|
+
for (const m of messages) {
|
|
417
|
+
if ((m.from || '').toLowerCase() !== agent)
|
|
418
|
+
continue;
|
|
419
|
+
if ((m.channel || 'general') !== 'general')
|
|
420
|
+
continue;
|
|
421
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
422
|
+
const hasTask = /\btask-[a-z0-9-]+\b/i.test(content);
|
|
423
|
+
const hasStrictTriplet = /1\)\s*(?:\*\*)?[^\n]*\bshipped\b/i.test(content)
|
|
424
|
+
&& /2\)\s*(?:\*\*)?[^\n]*\bblocker\b/i.test(content)
|
|
425
|
+
&& /3\)\s*(?:\*\*)?[^\n]*\bnext\b/i.test(content);
|
|
426
|
+
const hasLooseStatusSignals = (/\bshipped\b|\bartifact(?:s)?\b|\bcommit\b/i.test(content))
|
|
427
|
+
&& /\bblocker\b/i.test(content)
|
|
428
|
+
&& (/\bnext\b/i.test(content) || /\beta\b/i.test(content));
|
|
429
|
+
if (!hasTask || (!hasStrictTriplet && !hasLooseStatusSignals))
|
|
430
|
+
continue;
|
|
431
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
432
|
+
if (!ts)
|
|
433
|
+
continue;
|
|
434
|
+
if (!lastAt || ts > lastAt)
|
|
435
|
+
lastAt = ts;
|
|
436
|
+
}
|
|
437
|
+
return lastAt;
|
|
438
|
+
}
|
|
439
|
+
findLastTrioGeneralUpdate(messages) {
|
|
440
|
+
const trioSet = new Set(this.trioAgents);
|
|
441
|
+
let lastAt = 0;
|
|
442
|
+
for (const agent of trioSet) {
|
|
443
|
+
// Consider both #general and any-channel activity
|
|
444
|
+
const generalLast = this.getLatestGeneralMessageAt(messages, agent);
|
|
445
|
+
const anyLast = this.getLatestAnyMessageAt(messages, agent);
|
|
446
|
+
const agentLast = Math.max(generalLast, anyLast);
|
|
447
|
+
if (agentLast > lastAt)
|
|
448
|
+
lastAt = agentLast;
|
|
449
|
+
}
|
|
450
|
+
return lastAt || Date.now();
|
|
451
|
+
}
|
|
452
|
+
/** Check if agent posted a task comment recently (returns age in minutes or null) */
|
|
453
|
+
getTaskCommentAgeForAgent(taskId, agent, now) {
|
|
454
|
+
const comments = taskManager.getTaskComments(taskId);
|
|
455
|
+
if (!comments.length)
|
|
456
|
+
return null;
|
|
457
|
+
// Find most recent comment by this agent
|
|
458
|
+
let latestTs = 0;
|
|
459
|
+
for (const c of comments) {
|
|
460
|
+
if ((c.author || '').toLowerCase() !== agent)
|
|
461
|
+
continue;
|
|
462
|
+
const ts = this.parseTimestamp(c.timestamp);
|
|
463
|
+
if (ts > latestTs)
|
|
464
|
+
latestTs = ts;
|
|
465
|
+
}
|
|
466
|
+
if (!latestTs)
|
|
467
|
+
return null;
|
|
468
|
+
return Math.floor((now - latestTs) / 60_000);
|
|
469
|
+
}
|
|
470
|
+
/** Per-task focus window: agent started doing task recently → deep work window */
|
|
471
|
+
taskFocusWindows = new Map();
|
|
472
|
+
getTaskFocusWindow(taskId, agent, now) {
|
|
473
|
+
const key = `${agent}:${taskId}`;
|
|
474
|
+
const window = this.taskFocusWindows.get(key);
|
|
475
|
+
if (!window)
|
|
476
|
+
return null;
|
|
477
|
+
const elapsed = Math.floor((now - window.startedAt) / 60_000);
|
|
478
|
+
if (elapsed >= window.durationMin) {
|
|
479
|
+
this.taskFocusWindows.delete(key);
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return { active: true, remainingMin: window.durationMin - elapsed };
|
|
483
|
+
}
|
|
484
|
+
/** Start a focus window for a task (called when agent moves task to doing) */
|
|
485
|
+
startTaskFocusWindow(agent, taskId, durationMin = 45) {
|
|
486
|
+
const key = `${agent}:${taskId}`;
|
|
487
|
+
this.taskFocusWindows.set(key, { agent, startedAt: Date.now(), durationMin });
|
|
488
|
+
}
|
|
489
|
+
/** Count recent status updates from agent on a task that mention ETA but no artifacts */
|
|
490
|
+
countRecentEtaOnlyUpdates(messages, agent, taskId) {
|
|
491
|
+
if (!taskId)
|
|
492
|
+
return 0;
|
|
493
|
+
let etaOnlyCount = 0;
|
|
494
|
+
const cutoff = Date.now() - (4 * 60 * 60 * 1000); // last 4 hours
|
|
495
|
+
for (const m of messages) {
|
|
496
|
+
if ((m.from || '').toLowerCase() !== agent)
|
|
497
|
+
continue;
|
|
498
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
499
|
+
if (!ts || ts < cutoff)
|
|
500
|
+
continue;
|
|
501
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
502
|
+
if (!content.includes(taskId))
|
|
503
|
+
continue;
|
|
504
|
+
// Has ETA/time reference
|
|
505
|
+
const hasEta = /\beta\b|\b\d+\s*(?:min|m|h|hr|hour)/i.test(content);
|
|
506
|
+
if (!hasEta)
|
|
507
|
+
continue;
|
|
508
|
+
// Does NOT have artifact signal
|
|
509
|
+
const hasArtifact = /\b(shipped|artifact|commit|pr\s*#?\d+|pull request|merged|deployed|https?:\/\/github\.com)/i.test(content);
|
|
510
|
+
const hasBlocker = /\bblocker\b.*:.*\S/i.test(content) && !/\bblocker\b.*:\s*none\b/i.test(content);
|
|
511
|
+
if (!hasArtifact && !hasBlocker) {
|
|
512
|
+
etaOnlyCount++;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return etaOnlyCount;
|
|
516
|
+
}
|
|
517
|
+
findLastProductiveActionAt(messages, agent) {
|
|
518
|
+
let lastAt = null;
|
|
519
|
+
for (const m of messages) {
|
|
520
|
+
if ((m.from || '').toLowerCase() !== agent)
|
|
521
|
+
continue;
|
|
522
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
523
|
+
if (!content)
|
|
524
|
+
continue;
|
|
525
|
+
// Productive shipping signal: artifact/commit/proof/merged/shipped references.
|
|
526
|
+
const hasProductiveSignal = /\b(shipped|shipped:|artifact|artifacts|commit|proof|merged|pr\s*#?\d+|pull request|deployed)\b/i.test(content);
|
|
527
|
+
if (!hasProductiveSignal)
|
|
528
|
+
continue;
|
|
529
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
530
|
+
if (!ts)
|
|
531
|
+
continue;
|
|
532
|
+
if (!lastAt || ts > lastAt)
|
|
533
|
+
lastAt = ts;
|
|
534
|
+
}
|
|
535
|
+
return lastAt;
|
|
536
|
+
}
|
|
537
|
+
hasStaleDoingTask(agent, tasks, now) {
|
|
538
|
+
const thresholdMs = this.idleNudgeActiveTaskMaxAgeMin * 60_000;
|
|
539
|
+
return tasks.some((task) => {
|
|
540
|
+
if ((task.assignee || '').toLowerCase() !== agent)
|
|
541
|
+
return false;
|
|
542
|
+
if (task.status !== 'doing')
|
|
543
|
+
return false;
|
|
544
|
+
const updatedAt = this.parseTimestamp(task.updatedAt) || this.parseTimestamp(task.createdAt);
|
|
545
|
+
const lastActivityAt = this.getTaskLastActivityAt(task.id, updatedAt);
|
|
546
|
+
if (!lastActivityAt)
|
|
547
|
+
return false;
|
|
548
|
+
return now - lastActivityAt > thresholdMs;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
async getComplianceIncidents(now, messages) {
|
|
552
|
+
const fromWatchdog = await this.readWatchdogIncidents(now);
|
|
553
|
+
const inMemory = [];
|
|
554
|
+
const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
|
|
555
|
+
const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 1000 / 60);
|
|
556
|
+
if (trioSilenceMin > this.trioSilenceMaxMin) {
|
|
557
|
+
inMemory.push({
|
|
558
|
+
id: `inc-trio-${lastTrioGeneralUpdate}`,
|
|
559
|
+
agent: 'trio',
|
|
560
|
+
taskId: null,
|
|
561
|
+
type: 'trio-silence',
|
|
562
|
+
minutesOver: trioSilenceMin - this.trioSilenceMaxMin,
|
|
563
|
+
escalateTo: ['kai', 'link', 'pixel'],
|
|
564
|
+
openedAt: lastTrioGeneralUpdate + this.trioSilenceMaxMin * 60 * 1000,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return [...fromWatchdog, ...inMemory].sort((a, b) => b.openedAt - a.openedAt);
|
|
568
|
+
}
|
|
569
|
+
async readWatchdogIncidents(now) {
|
|
570
|
+
const paths = [
|
|
571
|
+
process.env.WATCHDOG_INCIDENT_LOG,
|
|
572
|
+
resolve(process.cwd(), '../workspace-link/openclaw-plugin-reflectt-node/incidents/watchdog-incidents.jsonl'),
|
|
573
|
+
resolve(process.cwd(), '../../workspace-link/openclaw-plugin-reflectt-node/incidents/watchdog-incidents.jsonl'),
|
|
574
|
+
].filter((p) => Boolean(p));
|
|
575
|
+
for (const path of paths) {
|
|
576
|
+
try {
|
|
577
|
+
const raw = await readFile(path, 'utf8');
|
|
578
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
579
|
+
const recent = lines.slice(-100);
|
|
580
|
+
const incidents = recent
|
|
581
|
+
.map((line) => {
|
|
582
|
+
try {
|
|
583
|
+
return JSON.parse(line);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
.filter((v) => v !== null)
|
|
590
|
+
.map((entry, idx) => this.mapWatchdogIncident(entry, idx, now))
|
|
591
|
+
.filter((v) => v !== null);
|
|
592
|
+
if (incidents.length > 0) {
|
|
593
|
+
return incidents;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// try next path
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
/** Maximum reasonable incident age (7 days). Anything older is stale/impossible. */
|
|
603
|
+
static MAX_INCIDENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
604
|
+
/**
|
|
605
|
+
* Clamp minutesOver to a reasonable bound and reject impossible durations.
|
|
606
|
+
* Returns null if the calculated age exceeds MAX_INCIDENT_AGE_MS (impossible/stale).
|
|
607
|
+
*/
|
|
608
|
+
clampIncidentAge(now, reference, thresholdMin) {
|
|
609
|
+
if (reference <= 0 || reference > now)
|
|
610
|
+
return null; // invalid timestamp
|
|
611
|
+
const ageMs = now - reference;
|
|
612
|
+
if (ageMs > TeamHealthMonitor.MAX_INCIDENT_AGE_MS)
|
|
613
|
+
return null; // impossible duration
|
|
614
|
+
return Math.max(0, Math.floor(ageMs / 60_000) - thresholdMin);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Validate that a task referenced by an incident still exists and is in a
|
|
618
|
+
* monitored status. Returns false for deleted or closed/done/cancelled tasks.
|
|
619
|
+
*/
|
|
620
|
+
isTaskStillActive(taskId) {
|
|
621
|
+
if (!taskId)
|
|
622
|
+
return true; // no task reference — allow (e.g. trio silence)
|
|
623
|
+
const task = taskManager.getTask(taskId);
|
|
624
|
+
if (!task)
|
|
625
|
+
return false; // deleted (hard DELETE)
|
|
626
|
+
const closedStatuses = new Set(['done', 'cancelled']);
|
|
627
|
+
return !closedStatuses.has(task.status);
|
|
628
|
+
}
|
|
629
|
+
mapWatchdogIncident(entry, idx, now) {
|
|
630
|
+
const openedAt = entry.at || now;
|
|
631
|
+
const taskId = entry.taskId ?? null;
|
|
632
|
+
const rawType = entry.type || '';
|
|
633
|
+
// Skip incidents for tasks that no longer exist (hard-deleted) or are closed
|
|
634
|
+
if (taskId && !this.isTaskStillActive(taskId))
|
|
635
|
+
return null;
|
|
636
|
+
if (rawType === 'trio_general_silence') {
|
|
637
|
+
const thresholdMin = Math.floor((entry.thresholdMs || this.trioSilenceMaxMin * 60_000) / 60_000);
|
|
638
|
+
const reference = entry.lastUpdateAt || openedAt;
|
|
639
|
+
const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
|
|
640
|
+
if (minutesOver === null)
|
|
641
|
+
return null; // impossible duration — skip
|
|
642
|
+
return {
|
|
643
|
+
id: `inc-watchdog-trio-${openedAt}-${idx}`,
|
|
644
|
+
agent: 'trio',
|
|
645
|
+
taskId,
|
|
646
|
+
type: 'trio-silence',
|
|
647
|
+
minutesOver,
|
|
648
|
+
escalateTo: ['kai', 'link', 'pixel'],
|
|
649
|
+
openedAt,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (rawType === 'stale_working') {
|
|
653
|
+
const agent = entry.agent || 'unknown';
|
|
654
|
+
const thresholdMin = Math.floor((entry.thresholdMs || this.workerCadenceMaxMin * 60_000) / 60_000);
|
|
655
|
+
const reference = entry.lastUpdateAt || openedAt;
|
|
656
|
+
const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
|
|
657
|
+
if (minutesOver === null)
|
|
658
|
+
return null; // impossible duration — skip
|
|
659
|
+
return {
|
|
660
|
+
id: `inc-watchdog-stale-${agent}-${openedAt}-${idx}`,
|
|
661
|
+
agent,
|
|
662
|
+
taskId,
|
|
663
|
+
type: 'stale-working',
|
|
664
|
+
minutesOver,
|
|
665
|
+
escalateTo: agent === 'pixel' ? ['kai', 'link'] : ['kai', 'pixel'],
|
|
666
|
+
openedAt,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (rawType === 'blocked_without_handoff') {
|
|
670
|
+
const agent = entry.agent || 'unknown';
|
|
671
|
+
const thresholdMin = Math.floor((entry.thresholdMs || this.blockedEscalationMin * 60_000) / 60_000);
|
|
672
|
+
const reference = entry.lastUpdateAt || entry.blockedSinceAt || openedAt;
|
|
673
|
+
const minutesOver = this.clampIncidentAge(now, reference, thresholdMin);
|
|
674
|
+
if (minutesOver === null)
|
|
675
|
+
return null; // impossible duration — skip
|
|
676
|
+
return {
|
|
677
|
+
id: `inc-watchdog-blocked-${agent}-${openedAt}-${idx}`,
|
|
678
|
+
agent,
|
|
679
|
+
taskId,
|
|
680
|
+
type: 'blocked-overdue',
|
|
681
|
+
minutesOver,
|
|
682
|
+
escalateTo: agent === 'pixel' ? ['kai', 'link'] : ['kai', 'pixel'],
|
|
683
|
+
openedAt,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
getLatestTaskCommentAgeMin(taskId, now) {
|
|
689
|
+
if (!taskId)
|
|
690
|
+
return null;
|
|
691
|
+
const comments = taskManager.getTaskComments(taskId);
|
|
692
|
+
if (!comments.length)
|
|
693
|
+
return null;
|
|
694
|
+
const latestTs = comments.reduce((max, c) => Math.max(max, this.parseTimestamp(c.timestamp)), 0);
|
|
695
|
+
if (!latestTs)
|
|
696
|
+
return null;
|
|
697
|
+
return Math.max(0, Math.floor((now - latestTs) / 60_000));
|
|
698
|
+
}
|
|
699
|
+
getLatestMentionAgeMin(messages, agent, now) {
|
|
700
|
+
const needle = `@${agent.toLowerCase()}`;
|
|
701
|
+
let latest = 0;
|
|
702
|
+
for (const m of messages) {
|
|
703
|
+
const from = (m?.from || '').toLowerCase();
|
|
704
|
+
if (!from || from === agent.toLowerCase())
|
|
705
|
+
continue;
|
|
706
|
+
const content = typeof m?.content === 'string' ? m.content.toLowerCase() : '';
|
|
707
|
+
if (!content.includes(needle))
|
|
708
|
+
continue;
|
|
709
|
+
const ts = this.parseTimestamp(m.timestamp);
|
|
710
|
+
if (ts > latest)
|
|
711
|
+
latest = ts;
|
|
712
|
+
}
|
|
713
|
+
if (!latest)
|
|
714
|
+
return null;
|
|
715
|
+
return Math.max(0, Math.floor((now - latest) / 60_000));
|
|
716
|
+
}
|
|
717
|
+
buildSuggestedAction(args) {
|
|
718
|
+
if (args.hasRecentBlocker || args.status === 'blocked') {
|
|
719
|
+
return 'Post blocker owner + unblock ETA in #general and request reviewer help if blocked >20m.';
|
|
720
|
+
}
|
|
721
|
+
if (args.idleWithActiveTask) {
|
|
722
|
+
return 'Post shipped/blocker/next+ETA now and either move task to validating with artifact or set blocked reason.';
|
|
723
|
+
}
|
|
724
|
+
if (args.status === 'silent' || args.status === 'offline') {
|
|
725
|
+
return 'Acknowledge in #general and confirm active lane status or set task to blocked/todo if paused.';
|
|
726
|
+
}
|
|
727
|
+
if (!args.hasTask && (args.status === 'idle' || args.status === 'active')) {
|
|
728
|
+
return 'Claim next backlog task or post explicit no-work state; avoid idle-without-lane drift.';
|
|
729
|
+
}
|
|
730
|
+
return 'Post a concrete next artifact ETA and keep task status aligned with actual execution state.';
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Get health status for all agents
|
|
734
|
+
*/
|
|
735
|
+
async getAgentHealthStatuses(now) {
|
|
736
|
+
const presences = presenceManager.getAllPresence();
|
|
737
|
+
const tasks = taskManager.listTasks({});
|
|
738
|
+
const messages = chatManager.getMessages({ limit: 300 });
|
|
739
|
+
const agentStatuses = [];
|
|
740
|
+
// Get unique agent list from all sources
|
|
741
|
+
const agentSet = new Set();
|
|
742
|
+
presences.forEach((p) => agentSet.add(p.agent));
|
|
743
|
+
tasks.forEach((t) => t.assignee && agentSet.add(t.assignee));
|
|
744
|
+
messages.forEach((m) => agentSet.add(m.from));
|
|
745
|
+
for (const agent of agentSet) {
|
|
746
|
+
const presence = presences.find((p) => p.agent === agent);
|
|
747
|
+
const agentTasks = tasks
|
|
748
|
+
.filter((t) => t.assignee === agent && t.status === 'doing')
|
|
749
|
+
.sort((a, b) => {
|
|
750
|
+
const aTs = Number(a.updatedAt || a.createdAt || 0);
|
|
751
|
+
const bTs = Number(b.updatedAt || b.createdAt || 0);
|
|
752
|
+
return bTs - aTs;
|
|
753
|
+
});
|
|
754
|
+
const agentMessages = messages.filter((m) => m.from === agent);
|
|
755
|
+
const lastSeen = presence?.lastUpdate || 0;
|
|
756
|
+
const minutesSinceLastSeen = Math.floor((now - lastSeen) / 1000 / 60);
|
|
757
|
+
// Count messages in last 24h
|
|
758
|
+
const oneDayAgo = now - (24 * 60 * 60 * 1000);
|
|
759
|
+
const messageCount24h = agentMessages.filter((m) => m.timestamp > oneDayAgo).length;
|
|
760
|
+
const lastProductiveAt = this.findLastProductiveActionAt(messages, agent);
|
|
761
|
+
const minutesSinceProductive = lastProductiveAt
|
|
762
|
+
? Math.floor((now - lastProductiveAt) / 1000 / 60)
|
|
763
|
+
: null;
|
|
764
|
+
// Determine status
|
|
765
|
+
let status = 'offline';
|
|
766
|
+
if (minutesSinceLastSeen < 15) {
|
|
767
|
+
status = 'active';
|
|
768
|
+
}
|
|
769
|
+
else if (minutesSinceLastSeen < 45) {
|
|
770
|
+
status = 'idle';
|
|
771
|
+
}
|
|
772
|
+
else if (minutesSinceLastSeen < 120) {
|
|
773
|
+
status = 'silent'; // >45min = >3 heartbeats
|
|
774
|
+
}
|
|
775
|
+
// Check for blockers in recent messages
|
|
776
|
+
const recentBlockers = this.findBlockersInMessages(agentMessages.slice(-10));
|
|
777
|
+
// Override status if explicitly blocked
|
|
778
|
+
if (presence?.status === 'blocked' || recentBlockers.length > 0) {
|
|
779
|
+
status = 'blocked';
|
|
780
|
+
}
|
|
781
|
+
const activeTask = agentTasks[0];
|
|
782
|
+
const hasActiveTask = Boolean(activeTask);
|
|
783
|
+
const idleWithActiveTask = hasActiveTask && minutesSinceLastSeen > 60;
|
|
784
|
+
const mentionAgeMin = this.getLatestMentionAgeMin(messages, agent, now);
|
|
785
|
+
const lastTransition = activeTask?.metadata?.last_transition;
|
|
786
|
+
const lastTransitionTs = this.parseTimestamp(lastTransition?.timestamp);
|
|
787
|
+
const isFlagged = status === 'blocked' || status === 'silent' || status === 'offline' || idleWithActiveTask;
|
|
788
|
+
const actionable_reason = isFlagged
|
|
789
|
+
? {
|
|
790
|
+
task_id: activeTask?.id || null,
|
|
791
|
+
last_task_comment_age_min: this.getLatestTaskCommentAgeMin(activeTask?.id, now),
|
|
792
|
+
last_transition: {
|
|
793
|
+
type: typeof lastTransition?.type === 'string' ? lastTransition.type : null,
|
|
794
|
+
actor: typeof lastTransition?.actor === 'string' ? lastTransition.actor : null,
|
|
795
|
+
age_min: lastTransitionTs ? Math.max(0, Math.floor((now - lastTransitionTs) / 60_000)) : null,
|
|
796
|
+
},
|
|
797
|
+
last_mention_age_min: mentionAgeMin,
|
|
798
|
+
suggested_action: this.buildSuggestedAction({
|
|
799
|
+
status,
|
|
800
|
+
idleWithActiveTask,
|
|
801
|
+
hasRecentBlocker: recentBlockers.length > 0,
|
|
802
|
+
hasTask: hasActiveTask,
|
|
803
|
+
}),
|
|
804
|
+
}
|
|
805
|
+
: null;
|
|
806
|
+
agentStatuses.push({
|
|
807
|
+
agent,
|
|
808
|
+
status,
|
|
809
|
+
lastSeen,
|
|
810
|
+
minutesSinceLastSeen,
|
|
811
|
+
currentTask: activeTask?.title,
|
|
812
|
+
activeTaskId: activeTask?.id,
|
|
813
|
+
activeTaskTitle: activeTask?.title,
|
|
814
|
+
activeTaskPrLink: this.extractTaskPrLink(activeTask),
|
|
815
|
+
recentBlockers,
|
|
816
|
+
messageCount24h,
|
|
817
|
+
lastProductiveAt,
|
|
818
|
+
minutesSinceProductive,
|
|
819
|
+
idleWithActiveTask,
|
|
820
|
+
actionable_reason,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return agentStatuses.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Extract blocker mentions from recent messages
|
|
827
|
+
*/
|
|
828
|
+
async extractBlockers() {
|
|
829
|
+
const messages = chatManager.getMessages({ limit: 200 });
|
|
830
|
+
const blockerMap = new Map();
|
|
831
|
+
for (const msg of messages) {
|
|
832
|
+
const blockers = this.findBlockersInMessages([msg]);
|
|
833
|
+
for (const blocker of blockers) {
|
|
834
|
+
const key = `${msg.from}:${blocker}`;
|
|
835
|
+
if (blockerMap.has(key)) {
|
|
836
|
+
const existing = blockerMap.get(key);
|
|
837
|
+
existing.mentionCount++;
|
|
838
|
+
existing.lastMentioned = msg.timestamp;
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
blockerMap.set(key, {
|
|
842
|
+
agent: msg.from,
|
|
843
|
+
blocker,
|
|
844
|
+
mentionCount: 1,
|
|
845
|
+
firstMentioned: msg.timestamp,
|
|
846
|
+
lastMentioned: msg.timestamp,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return Array.from(blockerMap.values())
|
|
852
|
+
.filter(b => b.mentionCount >= 2) // Only blockers mentioned multiple times
|
|
853
|
+
.sort((a, b) => b.lastMentioned - a.lastMentioned);
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Find blocker keywords in messages (improved with false-positive reduction)
|
|
857
|
+
*/
|
|
858
|
+
findBlockersInMessages(messages) {
|
|
859
|
+
const blockers = [];
|
|
860
|
+
for (const msg of messages) {
|
|
861
|
+
const from = (msg?.from || '').toLowerCase();
|
|
862
|
+
if (from === 'system' || from === 'watchdog') {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const rawContent = typeof msg?.content === 'string' ? msg.content : '';
|
|
866
|
+
if (!rawContent)
|
|
867
|
+
continue;
|
|
868
|
+
const content = rawContent.toLowerCase();
|
|
869
|
+
// Skip known non-actionable watchdog/template/fallback chatter.
|
|
870
|
+
const looksLikeStatusTemplate = /1\)\s*shipped:\s*</i.test(rawContent) ||
|
|
871
|
+
/2\)\s*blocker:\s*</i.test(rawContent) ||
|
|
872
|
+
/3\)\s*next:\s*</i.test(rawContent);
|
|
873
|
+
if (content.includes('post shipped / blocker / next+eta now') ||
|
|
874
|
+
content.includes('system watchdog') ||
|
|
875
|
+
content.includes('system fallback') ||
|
|
876
|
+
content.includes('idle nudge') ||
|
|
877
|
+
content.includes('required status now') ||
|
|
878
|
+
content.includes('system reminder: you appear idle') ||
|
|
879
|
+
content.includes('system escalation:') ||
|
|
880
|
+
looksLikeStatusTemplate) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
// Skip status reports and completed work mentions
|
|
884
|
+
if (content.includes('was blocked') ||
|
|
885
|
+
content.includes('unblocked') ||
|
|
886
|
+
content.includes('fixed') ||
|
|
887
|
+
content.includes('resolved') ||
|
|
888
|
+
content.includes('completed') ||
|
|
889
|
+
content.includes('done')) {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
for (const keyword of this.blockerKeywords) {
|
|
893
|
+
if (content.includes(keyword)) {
|
|
894
|
+
// Additional context check: must be near agent name or "I" to be real blocker
|
|
895
|
+
const hasContext = content.includes(' i ') ||
|
|
896
|
+
content.includes('i\'m') ||
|
|
897
|
+
content.includes('we\'re') ||
|
|
898
|
+
content.match(/@\w+/);
|
|
899
|
+
if (!hasContext)
|
|
900
|
+
continue;
|
|
901
|
+
// Extract context around the keyword
|
|
902
|
+
const index = content.indexOf(keyword);
|
|
903
|
+
const start = Math.max(0, index - 20);
|
|
904
|
+
const end = Math.min(content.length, index + keyword.length + 40);
|
|
905
|
+
const context = rawContent.substring(start, end).trim();
|
|
906
|
+
blockers.push(context);
|
|
907
|
+
break; // Only one blocker per message
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return blockers;
|
|
912
|
+
}
|
|
913
|
+
hasScopeSplitSignal(taskId) {
|
|
914
|
+
const comments = taskManager.getTaskComments(taskId);
|
|
915
|
+
const splitSignals = [
|
|
916
|
+
'deconflict',
|
|
917
|
+
'scope split',
|
|
918
|
+
'owner map',
|
|
919
|
+
'no overlap',
|
|
920
|
+
'non-overlap',
|
|
921
|
+
'boundary',
|
|
922
|
+
'aligned',
|
|
923
|
+
'split ownership',
|
|
924
|
+
'avoid duplicate',
|
|
925
|
+
];
|
|
926
|
+
return comments.some(comment => {
|
|
927
|
+
const content = comment.content.toLowerCase();
|
|
928
|
+
return splitSignals.some(signal => content.includes(signal));
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Detect overlapping work (agents working on similar things)
|
|
933
|
+
*/
|
|
934
|
+
async detectOverlaps() {
|
|
935
|
+
const tasks = taskManager
|
|
936
|
+
.listTasks({ status: 'doing' })
|
|
937
|
+
.filter(task => Boolean(task.assignee));
|
|
938
|
+
if (tasks.length < 2)
|
|
939
|
+
return [];
|
|
940
|
+
const taskKeywords = new Map();
|
|
941
|
+
for (const task of tasks) {
|
|
942
|
+
const keywords = this.extractKeywords(`${task.title} ${task.description || ''}`);
|
|
943
|
+
taskKeywords.set(task.id, new Set(keywords));
|
|
944
|
+
}
|
|
945
|
+
const overlapTopics = new Map();
|
|
946
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
947
|
+
for (let j = i + 1; j < tasks.length; j += 1) {
|
|
948
|
+
const a = tasks[i];
|
|
949
|
+
const b = tasks[j];
|
|
950
|
+
if (!a.assignee || !b.assignee)
|
|
951
|
+
continue;
|
|
952
|
+
if (a.assignee === b.assignee)
|
|
953
|
+
continue;
|
|
954
|
+
// If either task explicitly carries deconfliction/scope-split notes,
|
|
955
|
+
// treat this pair as resolved and suppress recurring overlap alerts.
|
|
956
|
+
if (this.hasScopeSplitSignal(a.id) || this.hasScopeSplitSignal(b.id)) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const aKeywords = taskKeywords.get(a.id) || new Set();
|
|
960
|
+
const bKeywords = taskKeywords.get(b.id) || new Set();
|
|
961
|
+
const shared = Array.from(aKeywords).filter(k => bKeywords.has(k));
|
|
962
|
+
// Require 2+ shared keywords to avoid generic single-word collisions.
|
|
963
|
+
if (shared.length < 2)
|
|
964
|
+
continue;
|
|
965
|
+
const topic = shared.slice(0, 2).join('+');
|
|
966
|
+
if (!overlapTopics.has(topic)) {
|
|
967
|
+
overlapTopics.set(topic, new Set());
|
|
968
|
+
}
|
|
969
|
+
overlapTopics.get(topic).add(a.assignee);
|
|
970
|
+
overlapTopics.get(topic).add(b.assignee);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const overlaps = [];
|
|
974
|
+
for (const [topic, agentsSet] of overlapTopics.entries()) {
|
|
975
|
+
const agents = Array.from(agentsSet);
|
|
976
|
+
if (agents.length < 2)
|
|
977
|
+
continue;
|
|
978
|
+
overlaps.push({
|
|
979
|
+
agents,
|
|
980
|
+
topic,
|
|
981
|
+
confidence: agents.length >= 3 ? 'high' : 'medium',
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
return overlaps;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Extract keywords from text
|
|
988
|
+
*/
|
|
989
|
+
extractKeywords(text) {
|
|
990
|
+
const stopWords = new Set([
|
|
991
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
|
|
992
|
+
// Domain-generic terms that cause overlap false positives.
|
|
993
|
+
'task', 'tasks', 'reflectt', 'node', 'agent', 'agents', 'lane', 'lanes', 'work', 'status',
|
|
994
|
+
]);
|
|
995
|
+
return text
|
|
996
|
+
.toLowerCase()
|
|
997
|
+
.split(/\W+/)
|
|
998
|
+
.filter(word => word.length > 3 && !stopWords.has(word))
|
|
999
|
+
.slice(0, 8);
|
|
1000
|
+
}
|
|
1001
|
+
shouldEmitCadenceAlert(key, now, cooldownMin) {
|
|
1002
|
+
const lastAt = this.cadenceAlertState.get(key);
|
|
1003
|
+
if (!lastAt)
|
|
1004
|
+
return true;
|
|
1005
|
+
const cooldown = Number(cooldownMin ?? this.cadenceAlertCooldownMin);
|
|
1006
|
+
const cooldownMs = cooldown * 60_000;
|
|
1007
|
+
return now - lastAt >= cooldownMs;
|
|
1008
|
+
}
|
|
1009
|
+
markCadenceAlert(key, now) {
|
|
1010
|
+
this.cadenceAlertState.set(key, now);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Enhanced suppression: check if agent has had ANY recent activity
|
|
1014
|
+
* (task comment, status update, chat message, task transition) since
|
|
1015
|
+
* the last alert for this key. If so, suppress the repeat.
|
|
1016
|
+
*/
|
|
1017
|
+
hasRecentActivitySinceLastAlert(agent, key, now) {
|
|
1018
|
+
const lastAlertAt = this.cadenceAlertState.get(key);
|
|
1019
|
+
if (!lastAlertAt)
|
|
1020
|
+
return false; // No prior alert → can't suppress based on activity
|
|
1021
|
+
const messages = chatManager.getMessages({ limit: 200 });
|
|
1022
|
+
// Check for any message from this agent after the last alert
|
|
1023
|
+
const hasRecentMessage = messages.some((m) => {
|
|
1024
|
+
const from = (m.from || '').toLowerCase();
|
|
1025
|
+
const ts = Number(m.timestamp || 0);
|
|
1026
|
+
return from === agent && ts > lastAlertAt;
|
|
1027
|
+
});
|
|
1028
|
+
if (hasRecentMessage)
|
|
1029
|
+
return true;
|
|
1030
|
+
// Check for task comments from this agent after last alert
|
|
1031
|
+
const tasks = taskManager.listTasks({ status: 'doing' });
|
|
1032
|
+
for (const task of tasks) {
|
|
1033
|
+
if ((task.assignee || '').toLowerCase() !== agent)
|
|
1034
|
+
continue;
|
|
1035
|
+
const comments = taskManager.getTaskComments?.(task.id) || [];
|
|
1036
|
+
const hasRecentComment = comments.some((c) => {
|
|
1037
|
+
const author = (c.author || '').toLowerCase();
|
|
1038
|
+
const ts = Number(c.createdAt || 0);
|
|
1039
|
+
return author === agent && ts > lastAlertAt;
|
|
1040
|
+
});
|
|
1041
|
+
if (hasRecentComment)
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
1044
|
+
// Check for task status changes after last alert
|
|
1045
|
+
const updatedTask = tasks.find((t) => {
|
|
1046
|
+
const assignee = (t.assignee || '').toLowerCase();
|
|
1047
|
+
const updatedAt = Number(t.updatedAt || 0);
|
|
1048
|
+
return assignee === agent && updatedAt > lastAlertAt;
|
|
1049
|
+
});
|
|
1050
|
+
if (updatedTask)
|
|
1051
|
+
return true;
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
async logWatchdogIncident(entry) {
|
|
1055
|
+
const path = process.env.WATCHDOG_INCIDENT_LOG
|
|
1056
|
+
|| resolve(process.cwd(), 'incidents/watchdog-incidents.jsonl');
|
|
1057
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1058
|
+
await appendFile(path, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
1059
|
+
}
|
|
1060
|
+
resolveIdleNudgeLane(agent, presenceTaskRaw, tasks, now) {
|
|
1061
|
+
return resolveIdleNudgeLane(agent, presenceTaskRaw, tasks, now, this.idleNudgeActiveTaskMaxAgeMin);
|
|
1062
|
+
}
|
|
1063
|
+
async runCadenceWatchdogTick(now = Date.now(), options) {
|
|
1064
|
+
const dryRun = options?.dryRun === true;
|
|
1065
|
+
const alerts = [];
|
|
1066
|
+
// Persist tick time so /health/system can prove this watchdog is actually firing.
|
|
1067
|
+
recordSystemLoopTick('cadence_watchdog', now);
|
|
1068
|
+
// Source of truth: unified policy config (file + env overlays).
|
|
1069
|
+
// Fallback: legacy env-based flags.
|
|
1070
|
+
const cadenceCfg = policyManager.get().cadenceWatchdog;
|
|
1071
|
+
const cadenceEnabled = cadenceCfg?.enabled ?? this.cadenceWatchdogEnabled;
|
|
1072
|
+
const silenceMin = Number(cadenceCfg?.silenceMin ?? this.cadenceSilenceMin);
|
|
1073
|
+
const workingStaleMin = Number(cadenceCfg?.workingStaleMin ?? this.cadenceWorkingStaleMin);
|
|
1074
|
+
const workingTaskMaxAgeMin = Number(cadenceCfg?.workingTaskMaxAgeMin ?? this.cadenceWorkingTaskMaxAgeMin);
|
|
1075
|
+
const alertCooldownMin = Number(cadenceCfg?.alertCooldownMin ?? this.cadenceAlertCooldownMin);
|
|
1076
|
+
if (!cadenceEnabled) {
|
|
1077
|
+
return { alerts };
|
|
1078
|
+
}
|
|
1079
|
+
// Startup grace period — suppress alerts for first N minutes after service restart.
|
|
1080
|
+
// Prevents a fresh restart from immediately firing a batch of stale alerts for all agents.
|
|
1081
|
+
if (now - this.systemStartTime < this.cadenceStartupGraceMs) {
|
|
1082
|
+
return { alerts };
|
|
1083
|
+
}
|
|
1084
|
+
const tasks = taskManager.listTasks({});
|
|
1085
|
+
const messages = chatManager.getMessages({ limit: 300 });
|
|
1086
|
+
const lastTrioGeneralUpdate = this.findLastTrioGeneralUpdate(messages);
|
|
1087
|
+
const trioSilenceMin = Math.floor((now - lastTrioGeneralUpdate) / 60_000);
|
|
1088
|
+
if (trioSilenceMin >= silenceMin) {
|
|
1089
|
+
const key = 'trio_general_silence';
|
|
1090
|
+
if (this.shouldEmitCadenceAlert(key, now, alertCooldownMin)) {
|
|
1091
|
+
// Enhanced suppression: skip if any trio member has had activity since last alert
|
|
1092
|
+
const anyTrioActive = this.trioAgents.some(a => this.hasRecentActivitySinceLastAlert(a, key, now));
|
|
1093
|
+
if (anyTrioActive) {
|
|
1094
|
+
// Activity detected — extend cooldown without re-alerting
|
|
1095
|
+
this.markCadenceAlert(key, now);
|
|
1096
|
+
}
|
|
1097
|
+
else if (this.trioAgents.some(a => this.hasRecentRateLimitFailure(messages, a, now, this.cadenceRateLimitSuppressMs))) {
|
|
1098
|
+
// Rate-limit failure suppressor: trio is silent due to provider outage, not genuine inactivity
|
|
1099
|
+
this.markCadenceAlert(key, now);
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
const content = `🔁 **[Product Enforcement] Cadence reset**: no #general update from trio for ${trioSilenceMin}m (threshold ${silenceMin}m). @kai @link @pixel post status now: 1) shipped 2) blocker 3) next+ETA. *(Automated — no leadership action needed.)*`;
|
|
1103
|
+
alerts.push(content);
|
|
1104
|
+
if (!dryRun) {
|
|
1105
|
+
await routeMessage({ from: 'system', content, category: 'escalation', severity: 'warning', mentions: ['kai', 'link', 'pixel'] });
|
|
1106
|
+
await this.logWatchdogIncident({
|
|
1107
|
+
type: 'trio_general_silence',
|
|
1108
|
+
at: now,
|
|
1109
|
+
thresholdMs: silenceMin * 60_000,
|
|
1110
|
+
lastUpdateAt: lastTrioGeneralUpdate,
|
|
1111
|
+
});
|
|
1112
|
+
this.markCadenceAlert(key, now);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const trioSet = new Set(this.trioAgents);
|
|
1118
|
+
const doingByAgent = new Map();
|
|
1119
|
+
for (const task of tasks) {
|
|
1120
|
+
if (!task.assignee)
|
|
1121
|
+
continue;
|
|
1122
|
+
// Only monitor actively-doing tasks; skip done/cancelled/blocked
|
|
1123
|
+
if (task.status !== 'doing')
|
|
1124
|
+
continue;
|
|
1125
|
+
const agent = (task.assignee || '').toLowerCase();
|
|
1126
|
+
if (!trioSet.has(agent))
|
|
1127
|
+
continue;
|
|
1128
|
+
const taskTs = Number(task.updatedAt || task.createdAt || 0);
|
|
1129
|
+
const taskAgeMin = taskTs > 0 ? Math.floor((now - taskTs) / 60_000) : Number.MAX_SAFE_INTEGER;
|
|
1130
|
+
if (taskAgeMin > workingTaskMaxAgeMin) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const current = doingByAgent.get(agent);
|
|
1134
|
+
const currentTs = current ? Number(current.updatedAt || current.createdAt || 0) : 0;
|
|
1135
|
+
if (!current || taskTs >= currentTs) {
|
|
1136
|
+
doingByAgent.set(agent, task);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const workingTasks = Array.from(doingByAgent.values());
|
|
1140
|
+
for (const task of workingTasks) {
|
|
1141
|
+
const agent = (task.assignee || '').toLowerCase();
|
|
1142
|
+
// Re-check task status at nudge time (guards against race between list and nudge)
|
|
1143
|
+
// Also handles deleted tasks: if getTask returns null, the task was hard-deleted
|
|
1144
|
+
const freshTask = taskManager.getTask(task.id);
|
|
1145
|
+
if (!freshTask)
|
|
1146
|
+
continue; // task was deleted — skip alert
|
|
1147
|
+
if (freshTask.status !== 'doing')
|
|
1148
|
+
continue; // task moved to done/cancelled/blocked — skip
|
|
1149
|
+
const lastGeneralAt = this.getLatestGeneralMessageAt(messages, agent);
|
|
1150
|
+
const lastAnyAt = this.getLatestAnyMessageAt(messages, agent);
|
|
1151
|
+
// Use the more recent of #general or any-channel activity
|
|
1152
|
+
const lastAt = Math.max(lastGeneralAt, lastAnyAt);
|
|
1153
|
+
const rawStaleMin = lastAt > 0 ? Math.floor((now - lastAt) / 60_000) : 9999;
|
|
1154
|
+
// Cap at MAX_INCIDENT_AGE to prevent impossible durations (e.g. from stale cache)
|
|
1155
|
+
const maxStaleMin = Math.floor(TeamHealthMonitor.MAX_INCIDENT_AGE_MS / 60_000);
|
|
1156
|
+
const staleMin = Math.min(rawStaleMin, maxStaleMin);
|
|
1157
|
+
if (staleMin < workingStaleMin)
|
|
1158
|
+
continue;
|
|
1159
|
+
// Also check task comments as activity signal
|
|
1160
|
+
const taskCommentAge = this.getTaskCommentAgeForAgent(task.id, agent, now);
|
|
1161
|
+
if (taskCommentAge !== null && taskCommentAge < workingStaleMin)
|
|
1162
|
+
continue;
|
|
1163
|
+
const key = `stale_working:${agent}:${task.id}`;
|
|
1164
|
+
if (!this.shouldEmitCadenceAlert(key, now, alertCooldownMin))
|
|
1165
|
+
continue;
|
|
1166
|
+
// Enhanced suppression: skip if agent has had ANY activity since last alert
|
|
1167
|
+
if (this.hasRecentActivitySinceLastAlert(agent, key, now)) {
|
|
1168
|
+
this.markCadenceAlert(key, now);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
// Also suppress first-time alerts if agent posted ANY #general message recently
|
|
1172
|
+
const agentLastGeneralAt = this.getLatestGeneralMessageAt(messages, agent);
|
|
1173
|
+
if (agentLastGeneralAt > 0) {
|
|
1174
|
+
const sinceGeneralMin = Math.floor((now - agentLastGeneralAt) / 60_000);
|
|
1175
|
+
if (sinceGeneralMin < workingStaleMin)
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
// Rate-limit failure suppressor: if the agent's silence is caused by infra failures
|
|
1179
|
+
// (rate limits, provider outage), suppress the alert and extend cooldown silently.
|
|
1180
|
+
// Prevents noisy false positives during provider outages when agents are trying but blocked.
|
|
1181
|
+
if (this.hasRecentRateLimitFailure(messages, agent, now, this.cadenceRateLimitSuppressMs)) {
|
|
1182
|
+
this.markCadenceAlert(key, now);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const content = `@${agent} [Product Enforcement] status=working with no update for ${staleMin}m on ${task.id}. Post status now: 1) shipped 2) blocker 3) next+ETA. *(Automated — no leadership action needed.)*`;
|
|
1186
|
+
alerts.push(content);
|
|
1187
|
+
if (!dryRun) {
|
|
1188
|
+
await routeMessage({ from: 'system', content, category: 'watchdog-alert', severity: 'info', taskId: task.id, mentions: [agent, 'kai', 'pixel'] });
|
|
1189
|
+
await this.logWatchdogIncident({
|
|
1190
|
+
type: 'stale_working',
|
|
1191
|
+
at: now,
|
|
1192
|
+
agent,
|
|
1193
|
+
taskId: task.id,
|
|
1194
|
+
thresholdMs: workingStaleMin * 60_000,
|
|
1195
|
+
lastUpdateAt: lastAt || null,
|
|
1196
|
+
workingSinceAt: task.updatedAt || task.createdAt || null,
|
|
1197
|
+
});
|
|
1198
|
+
this.markCadenceAlert(key, now);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return { alerts };
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Ensure the mention_rescue_state SQLite table exists.
|
|
1205
|
+
* Called lazily on first tick to avoid import-time side effects.
|
|
1206
|
+
*/
|
|
1207
|
+
ensureMentionRescueDb() {
|
|
1208
|
+
if (this.mentionRescueDbInitialized)
|
|
1209
|
+
return;
|
|
1210
|
+
try {
|
|
1211
|
+
const db = getDb();
|
|
1212
|
+
db.exec(`
|
|
1213
|
+
CREATE TABLE IF NOT EXISTS mention_rescue_state (
|
|
1214
|
+
thread_key TEXT PRIMARY KEY,
|
|
1215
|
+
message_ids TEXT NOT NULL DEFAULT '[]',
|
|
1216
|
+
rescued_at INTEGER NOT NULL,
|
|
1217
|
+
rescue_count INTEGER NOT NULL DEFAULT 1
|
|
1218
|
+
)
|
|
1219
|
+
`);
|
|
1220
|
+
this.mentionRescueDbInitialized = true;
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// DB not available — fall back to in-memory only
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Extract which trio agents were actually mentioned in a message.
|
|
1228
|
+
* Returns lowercase agent ids (subset of this.trioAgents).
|
|
1229
|
+
*/
|
|
1230
|
+
extractMentionedTrioAgents(content) {
|
|
1231
|
+
if (!content)
|
|
1232
|
+
return [];
|
|
1233
|
+
const matches = content.match(/@(kai|link|pixel)\b/gi) || [];
|
|
1234
|
+
const uniq = new Set();
|
|
1235
|
+
for (const m of matches) {
|
|
1236
|
+
const name = m.replace('@', '').toLowerCase();
|
|
1237
|
+
if (this.trioAgents.includes(name))
|
|
1238
|
+
uniq.add(name);
|
|
1239
|
+
}
|
|
1240
|
+
return Array.from(uniq);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Build a thread-level idempotency key for mention-rescue.
|
|
1244
|
+
* Groups mentions by thread context so that multiple messages in the same
|
|
1245
|
+
* thread/channel with the same mentioned agents produce one rescue, not many.
|
|
1246
|
+
*
|
|
1247
|
+
* Key format: `{channel}:{threadId || 'root'}:{sortedAgents}`
|
|
1248
|
+
*/
|
|
1249
|
+
buildMentionThreadKey(mention) {
|
|
1250
|
+
const channel = String(mention.channel || 'general');
|
|
1251
|
+
const threadId = String(mention.threadId || mention.thread_id || 'root');
|
|
1252
|
+
const content = typeof mention.content === 'string' ? mention.content : '';
|
|
1253
|
+
const agents = this.extractMentionedTrioAgents(content)
|
|
1254
|
+
.slice()
|
|
1255
|
+
.sort()
|
|
1256
|
+
.join(',');
|
|
1257
|
+
return `${channel}:${threadId}:${agents}`;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Check if a thread key has already been rescued (persisted or in-memory).
|
|
1261
|
+
*/
|
|
1262
|
+
isThreadRescued(threadKey, cooldownMs, now) {
|
|
1263
|
+
// Check in-memory first (covers current session)
|
|
1264
|
+
for (const [, entry] of this.mentionRescueState) {
|
|
1265
|
+
if (entry.rescueCount > 0) {
|
|
1266
|
+
// In-memory entries are keyed by mentionId, not threadKey — checked below
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
// Check SQLite for cross-restart persistence
|
|
1270
|
+
try {
|
|
1271
|
+
this.ensureMentionRescueDb();
|
|
1272
|
+
const db = getDb();
|
|
1273
|
+
const row = db.prepare('SELECT rescued_at, rescue_count FROM mention_rescue_state WHERE thread_key = ?').get(threadKey);
|
|
1274
|
+
if (row && row.rescue_count > 0) {
|
|
1275
|
+
// Within cooldown window — still suppressed
|
|
1276
|
+
if (now - row.rescued_at < cooldownMs)
|
|
1277
|
+
return true;
|
|
1278
|
+
// Beyond cooldown — allow re-rescue (but this is unusual; max age usually prevents it)
|
|
1279
|
+
return true; // One-shot per thread: once rescued, always rescued
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
// DB read failed — rely on in-memory only
|
|
1284
|
+
}
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Record a thread rescue in both in-memory state and SQLite.
|
|
1289
|
+
*/
|
|
1290
|
+
recordThreadRescue(threadKey, mentionId, now) {
|
|
1291
|
+
// In-memory
|
|
1292
|
+
this.mentionRescueState.set(mentionId, { lastRescueAt: now, rescueCount: 1 });
|
|
1293
|
+
this.mentionRescueLastAt = now;
|
|
1294
|
+
// SQLite persistence
|
|
1295
|
+
try {
|
|
1296
|
+
this.ensureMentionRescueDb();
|
|
1297
|
+
const db = getDb();
|
|
1298
|
+
const existing = db.prepare('SELECT message_ids, rescue_count FROM mention_rescue_state WHERE thread_key = ?').get(threadKey);
|
|
1299
|
+
if (existing) {
|
|
1300
|
+
const ids = JSON.parse(existing.message_ids || '[]');
|
|
1301
|
+
if (!ids.includes(mentionId))
|
|
1302
|
+
ids.push(mentionId);
|
|
1303
|
+
db.prepare('UPDATE mention_rescue_state SET message_ids = ?, rescued_at = ?, rescue_count = rescue_count + 1 WHERE thread_key = ?').run(JSON.stringify(ids), now, threadKey);
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
db.prepare('INSERT INTO mention_rescue_state (thread_key, message_ids, rescued_at, rescue_count) VALUES (?, ?, ?, 1)').run(threadKey, JSON.stringify([mentionId]), now);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
// DB write failed — in-memory state still covers current session
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Prune stale rescue state entries from both in-memory and SQLite.
|
|
1315
|
+
*/
|
|
1316
|
+
pruneRescueState(now) {
|
|
1317
|
+
const pruneThresholdMs = 60 * 60_000;
|
|
1318
|
+
// Prune in-memory
|
|
1319
|
+
for (const [key, entry] of this.mentionRescueState) {
|
|
1320
|
+
if (now - entry.lastRescueAt > pruneThresholdMs) {
|
|
1321
|
+
this.mentionRescueState.delete(key);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
// Prune SQLite
|
|
1325
|
+
try {
|
|
1326
|
+
this.ensureMentionRescueDb();
|
|
1327
|
+
const db = getDb();
|
|
1328
|
+
db.prepare('DELETE FROM mention_rescue_state WHERE rescued_at < ?').run(now - pruneThresholdMs);
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
// DB prune failed — non-critical
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async runMentionRescueTick(now = Date.now(), options) {
|
|
1335
|
+
const dryRun = options?.dryRun === true;
|
|
1336
|
+
const rescued = [];
|
|
1337
|
+
// Persist tick time so /health/system can prove this watchdog is actually firing.
|
|
1338
|
+
recordSystemLoopTick('mention_rescue', now);
|
|
1339
|
+
const policy = policyManager.get();
|
|
1340
|
+
const cfg = policy.mentionRescue;
|
|
1341
|
+
if (!cfg?.enabled) {
|
|
1342
|
+
return { rescued };
|
|
1343
|
+
}
|
|
1344
|
+
// Initialize DB table on first tick
|
|
1345
|
+
this.ensureMentionRescueDb();
|
|
1346
|
+
const messages = chatManager.getMessages({ limit: 300 });
|
|
1347
|
+
const mentions = messages.filter((m) => {
|
|
1348
|
+
const from = (m.from || '').toLowerCase();
|
|
1349
|
+
const channel = (m.channel || 'general');
|
|
1350
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
1351
|
+
if (channel !== 'general' || from !== 'ryan')
|
|
1352
|
+
return false;
|
|
1353
|
+
return /@(kai|link|pixel)\b/i.test(content);
|
|
1354
|
+
});
|
|
1355
|
+
const trioSet = new Set(this.trioAgents);
|
|
1356
|
+
// Guardrails: never allow instant mention-rescue (creates #general spam).
|
|
1357
|
+
const delayMin = Math.max(3, Number(cfg.delayMin || 0));
|
|
1358
|
+
const cooldownMin = Math.max(1, Number(cfg.cooldownMin || 0));
|
|
1359
|
+
const globalCooldownMin = Math.max(1, Number(cfg.globalCooldownMin || 0));
|
|
1360
|
+
const delayMs = delayMin * 60_000;
|
|
1361
|
+
const cooldownMs = cooldownMin * 60_000;
|
|
1362
|
+
const globalCooldownMs = globalCooldownMin * 60_000;
|
|
1363
|
+
// Maximum age for mentions to be eligible for rescue (30 minutes).
|
|
1364
|
+
// Prevents stale mentions from hours/days ago from triggering infinite rescue loops.
|
|
1365
|
+
const maxMentionAgeMs = 30 * 60_000;
|
|
1366
|
+
// Track which thread keys we've already processed this tick to avoid
|
|
1367
|
+
// emitting multiple rescues for different messages in the same thread.
|
|
1368
|
+
const processedThreadKeys = new Set();
|
|
1369
|
+
for (const mention of mentions) {
|
|
1370
|
+
const mentionId = String(mention.id || mention.timestamp || '');
|
|
1371
|
+
if (!mentionId)
|
|
1372
|
+
continue;
|
|
1373
|
+
const mentionAt = Number(mention.timestamp || 0);
|
|
1374
|
+
if (!mentionAt || now - mentionAt < delayMs)
|
|
1375
|
+
continue;
|
|
1376
|
+
// Skip stale mentions — if a mention is older than maxMentionAgeMs, stop rescuing it.
|
|
1377
|
+
// This prevents infinite rescue loops for old unresolved mentions.
|
|
1378
|
+
if (now - mentionAt > maxMentionAgeMs)
|
|
1379
|
+
continue;
|
|
1380
|
+
// Global cooldown to avoid duplicate fallback nudges across near-identical mentions.
|
|
1381
|
+
if (now - this.mentionRescueLastAt < globalCooldownMs)
|
|
1382
|
+
continue;
|
|
1383
|
+
const mentionContent = typeof mention.content === 'string' ? mention.content : '';
|
|
1384
|
+
const mentionedAgents = this.extractMentionedTrioAgents(mentionContent);
|
|
1385
|
+
if (mentionedAgents.length === 0)
|
|
1386
|
+
continue;
|
|
1387
|
+
// ── Thread-level idempotency ─────────────────────────────────────
|
|
1388
|
+
// Build a thread key that groups mentions by channel + thread + mentioned agents.
|
|
1389
|
+
// This prevents duplicate rescues when Ryan sends multiple messages in the
|
|
1390
|
+
// same thread mentioning the same agents.
|
|
1391
|
+
const threadKey = this.buildMentionThreadKey(mention);
|
|
1392
|
+
// Skip if we already processed this thread key during this tick
|
|
1393
|
+
if (processedThreadKeys.has(threadKey))
|
|
1394
|
+
continue;
|
|
1395
|
+
// Skip if this thread was already rescued (persisted across restarts)
|
|
1396
|
+
if (this.isThreadRescued(threadKey, cooldownMs, now)) {
|
|
1397
|
+
processedThreadKeys.add(threadKey);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
// Also check in-memory per-message state (backward compat)
|
|
1401
|
+
const rescueEntry = this.mentionRescueState.get(mentionId);
|
|
1402
|
+
if (rescueEntry && rescueEntry.rescueCount > 0)
|
|
1403
|
+
continue;
|
|
1404
|
+
// Cancel mention-rescue only when the trio reply is in the same channel and
|
|
1405
|
+
// (when applicable) the same thread context as the original mention.
|
|
1406
|
+
//
|
|
1407
|
+
// Without this scoping, any trio chatter anywhere after the mention timestamp
|
|
1408
|
+
// can incorrectly suppress the rescue.
|
|
1409
|
+
const mentionChannel = String(mention.channel || 'general');
|
|
1410
|
+
const mentionThreadId = ((typeof mention.threadId === 'string' ? mention.threadId : null) ||
|
|
1411
|
+
(typeof mention.thread_id === 'string' ? mention.thread_id : null) ||
|
|
1412
|
+
null);
|
|
1413
|
+
const replied = messages.some((m) => {
|
|
1414
|
+
const from = (m.from || '').toLowerCase();
|
|
1415
|
+
if (!trioSet.has(from))
|
|
1416
|
+
return false;
|
|
1417
|
+
const ts = Number(m.timestamp || 0);
|
|
1418
|
+
if (!(ts > mentionAt))
|
|
1419
|
+
return false;
|
|
1420
|
+
const channel = String(m.channel || 'general');
|
|
1421
|
+
if (channel !== mentionChannel)
|
|
1422
|
+
return false;
|
|
1423
|
+
const threadId = (typeof m.threadId === 'string' ? m.threadId : null) ||
|
|
1424
|
+
(typeof m.thread_id === 'string' ? m.thread_id : null) ||
|
|
1425
|
+
null;
|
|
1426
|
+
// If the mention itself is inside a thread, only count replies inside that same thread.
|
|
1427
|
+
if (mentionThreadId)
|
|
1428
|
+
return threadId === mentionThreadId;
|
|
1429
|
+
// Root mention: count either a root reply (no thread) or a reply in the thread
|
|
1430
|
+
// directly attached to the mention (threadId === mentionId).
|
|
1431
|
+
if (threadId)
|
|
1432
|
+
return threadId === mentionId;
|
|
1433
|
+
return true;
|
|
1434
|
+
});
|
|
1435
|
+
if (replied) {
|
|
1436
|
+
processedThreadKeys.add(threadKey);
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
// Focus mode is a hard suppressor for fallback nudges.
|
|
1440
|
+
const anyFocused = mentionedAgents.some(a => presenceManager.isInFocus(a) !== null);
|
|
1441
|
+
if (anyFocused)
|
|
1442
|
+
continue;
|
|
1443
|
+
const mentionList = mentionedAgents.map(a => `@${a}`).join(' ');
|
|
1444
|
+
const content = `[[reply_to:${mentionId}]] system fallback: mention received. ${mentionList} are being nudged to respond.`;
|
|
1445
|
+
rescued.push(content);
|
|
1446
|
+
if (!dryRun) {
|
|
1447
|
+
await routeMessage({
|
|
1448
|
+
from: 'system',
|
|
1449
|
+
content,
|
|
1450
|
+
category: 'mention-rescue',
|
|
1451
|
+
severity: 'warning',
|
|
1452
|
+
mentions: mentionedAgents,
|
|
1453
|
+
// Keep the fallback in the same channel as the original mention.
|
|
1454
|
+
forceChannel: String(mention.channel || 'general'),
|
|
1455
|
+
});
|
|
1456
|
+
this.recordThreadRescue(threadKey, mentionId, now);
|
|
1457
|
+
}
|
|
1458
|
+
processedThreadKeys.add(threadKey);
|
|
1459
|
+
}
|
|
1460
|
+
// Prune stale state from both in-memory and SQLite
|
|
1461
|
+
this.pruneRescueState(now);
|
|
1462
|
+
return { rescued };
|
|
1463
|
+
}
|
|
1464
|
+
async runIdleNudgeTick(now = Date.now(), options) {
|
|
1465
|
+
const dryRun = options?.dryRun === true;
|
|
1466
|
+
const nudged = [];
|
|
1467
|
+
const decisions = [];
|
|
1468
|
+
// Persist tick time so /health/system can prove this watchdog is actually firing.
|
|
1469
|
+
recordSystemLoopTick('idle_nudge', now);
|
|
1470
|
+
const presences = presenceManager.getAllPresence();
|
|
1471
|
+
const tasks = taskManager.listTasks({});
|
|
1472
|
+
const taskById = new Map(tasks.map((t) => [t.id, t]));
|
|
1473
|
+
const messages = chatManager.getMessages({ limit: 300 });
|
|
1474
|
+
for (const presence of presences) {
|
|
1475
|
+
const agent = (presence.agent || '').toLowerCase();
|
|
1476
|
+
if (!agent)
|
|
1477
|
+
continue;
|
|
1478
|
+
const lastActiveAt = presence.last_active || presence.lastUpdate || 0;
|
|
1479
|
+
const inactivityMin = lastActiveAt ? Math.floor((now - lastActiveAt) / 60_000) : 0;
|
|
1480
|
+
const tier = inactivityMin >= this.idleNudgeEscalateMin ? 2 : 1;
|
|
1481
|
+
const lane = this.resolveIdleNudgeLane(agent, presence.task, tasks, now);
|
|
1482
|
+
const taskId = lane.selectedTaskId;
|
|
1483
|
+
const baseDecision = {
|
|
1484
|
+
agent,
|
|
1485
|
+
taskId,
|
|
1486
|
+
idleMinutes: inactivityMin,
|
|
1487
|
+
warnMin: this.idleNudgeWarnMin,
|
|
1488
|
+
escalateMin: this.idleNudgeEscalateMin,
|
|
1489
|
+
cooldownMin: this.idleNudgeCooldownMin,
|
|
1490
|
+
recentSuppressMin: this.idleNudgeSuppressRecentMin,
|
|
1491
|
+
lane,
|
|
1492
|
+
at: now,
|
|
1493
|
+
};
|
|
1494
|
+
if (!this.idleNudgeEnabled) {
|
|
1495
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'disabled', renderedMessage: null });
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (this.idleNudgeExcluded.has(agent)) {
|
|
1499
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'excluded', renderedMessage: null });
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
// Respect focus mode — suppress idle nudges for focused agents
|
|
1503
|
+
const focusState = presenceManager.isInFocus(agent);
|
|
1504
|
+
if (focusState) {
|
|
1505
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'focus-mode-active', renderedMessage: null });
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (presence.status === 'offline') {
|
|
1509
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'offline', renderedMessage: null });
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
// Queue-clear (no doing/blocked/validating task) is eligible for engagement nudges — see no-active-lane handler below.
|
|
1513
|
+
if (presence.status === 'blocked') {
|
|
1514
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'blocked-task-suppressed', renderedMessage: null });
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
if (!lastActiveAt) {
|
|
1518
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'no-last-active', renderedMessage: null });
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
const hasStaleDoingTask = this.hasStaleDoingTask(agent, tasks, now);
|
|
1522
|
+
const lastProductiveActionAt = this.findLastProductiveActionAt(messages, agent);
|
|
1523
|
+
if (!hasStaleDoingTask && lastProductiveActionAt) {
|
|
1524
|
+
const sinceShipMin = Math.floor((now - lastProductiveActionAt) / 60_000);
|
|
1525
|
+
if (sinceShipMin < this.idleNudgeShipCooldownMin) {
|
|
1526
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-shipped-cooldown', renderedMessage: null });
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
// Task-comment activity suppression: treat task comments as not-idle
|
|
1531
|
+
if (taskId) {
|
|
1532
|
+
const taskCommentAge = this.getTaskCommentAgeForAgent(taskId, agent, now);
|
|
1533
|
+
if (taskCommentAge !== null && taskCommentAge < 30) {
|
|
1534
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-task-comment', renderedMessage: null });
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// Per-task focus window: 45-60m deep work suppression
|
|
1539
|
+
if (taskId) {
|
|
1540
|
+
const focusWindow = this.getTaskFocusWindow(taskId, agent, now);
|
|
1541
|
+
if (focusWindow && focusWindow.active) {
|
|
1542
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'task-focus-window', renderedMessage: null });
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (inactivityMin < this.idleNudgeWarnMin) {
|
|
1547
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'below-warn-threshold', renderedMessage: null });
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
// Suppress if agent posted ANY message recently (any channel, not just #general)
|
|
1551
|
+
const lastAnyMsgAt = this.getLatestAnyMessageAt(messages, agent);
|
|
1552
|
+
if (lastAnyMsgAt) {
|
|
1553
|
+
const sinceLastMsgMin = Math.floor((now - lastAnyMsgAt) / 60_000);
|
|
1554
|
+
if (sinceLastMsgMin < this.idleNudgeSuppressRecentMin) {
|
|
1555
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-activity-suppressed', renderedMessage: null });
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const lastValidStatusAt = this.findLastValidStatusAt(messages, agent);
|
|
1560
|
+
if (lastValidStatusAt) {
|
|
1561
|
+
const sinceLastStatusMin = Math.floor((now - lastValidStatusAt) / 60_000);
|
|
1562
|
+
if (sinceLastStatusMin < this.idleNudgeSuppressRecentMin) {
|
|
1563
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'recent-activity-suppressed', renderedMessage: null });
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
const state = this.idleNudgeState.get(agent);
|
|
1568
|
+
if (state) {
|
|
1569
|
+
const sinceNudgeMin = Math.floor((now - state.lastNudgeAt) / 60_000);
|
|
1570
|
+
if (sinceNudgeMin < this.idleNudgeCooldownMin) {
|
|
1571
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'cooldown-active', renderedMessage: null });
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const hasValidatingTask = tasks.some((t) => (t.assignee || '').toLowerCase() === agent && t.status === 'validating');
|
|
1576
|
+
if (hasValidatingTask) {
|
|
1577
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'validating-task-suppressed', renderedMessage: null });
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
// Engagement nudge: if agent is idle and has no active doing lane, prompt them to pull/claim work.
|
|
1581
|
+
if (lane.laneReason === 'no-active-lane') {
|
|
1582
|
+
const signature = `queue-clear:${agent}`;
|
|
1583
|
+
if (state && state.lastSignature === signature && state.unchangedNudgeCount >= 2) {
|
|
1584
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'max-repeat-reached', renderedMessage: null });
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
const intro = tier === 1
|
|
1588
|
+
? `@${agent} system reminder: you appear idle for ${inactivityMin}m and have no active task. Pull work now.`
|
|
1589
|
+
: `@${agent} @kai system escalation: ${inactivityMin}m idle and no active task. Pull work now.`;
|
|
1590
|
+
const template = [
|
|
1591
|
+
`1) Pull: GET /tasks/next?agent=${agent}`,
|
|
1592
|
+
`2) Claim: PATCH /tasks/<id> { "status": "doing", "assignee": "${agent}" }`,
|
|
1593
|
+
'3) Post: /tasks/<id>/comments with 1) shipped 2) blocker 3) next+ETA',
|
|
1594
|
+
].join('\n');
|
|
1595
|
+
const renderedMessage = `${intro}\n${template}`;
|
|
1596
|
+
decisions.push({
|
|
1597
|
+
...baseDecision,
|
|
1598
|
+
decision: tier === 1 ? 'warn' : 'escalate',
|
|
1599
|
+
reason: 'queue-clear',
|
|
1600
|
+
renderedMessage,
|
|
1601
|
+
});
|
|
1602
|
+
if (dryRun) {
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
await routeMessage({
|
|
1606
|
+
from: 'system',
|
|
1607
|
+
content: renderedMessage,
|
|
1608
|
+
category: 'watchdog-alert',
|
|
1609
|
+
severity: tier === 2 ? 'warning' : 'info',
|
|
1610
|
+
mentions: tier === 2 ? [agent, 'kai'] : [agent],
|
|
1611
|
+
});
|
|
1612
|
+
const unchangedNudgeCount = state && state.lastSignature === signature
|
|
1613
|
+
? state.unchangedNudgeCount + 1
|
|
1614
|
+
: 1;
|
|
1615
|
+
this.idleNudgeState.set(agent, {
|
|
1616
|
+
lastNudgeAt: now,
|
|
1617
|
+
lastTier: tier,
|
|
1618
|
+
lastSignature: signature,
|
|
1619
|
+
unchangedNudgeCount,
|
|
1620
|
+
});
|
|
1621
|
+
nudged.push(agent);
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (lane.laneReason === 'stale-lane') {
|
|
1625
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'stale-active-task', renderedMessage: null });
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
if (lane.laneReason === 'ambiguous-lane') {
|
|
1629
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'ambiguous-active-task', renderedMessage: null });
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
if (lane.laneReason === 'presence-task-mismatch') {
|
|
1633
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'presence-task-mismatch', renderedMessage: null });
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
// Safety guard: never emit when an active task is missing/invalid.
|
|
1637
|
+
if (!taskId || !/^task-[a-z0-9-]+$/i.test(taskId)) {
|
|
1638
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'missing-active-task', renderedMessage: null });
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const selectedTask = taskById.get(taskId);
|
|
1642
|
+
if (selectedTask?.status === 'blocked') {
|
|
1643
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'blocked-task-suppressed', renderedMessage: null });
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
if (selectedTask && selectedTask.status !== 'doing') {
|
|
1647
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'done-task-suppressed', renderedMessage: null });
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
const signature = `${taskId}:${selectedTask?.status || 'unknown'}:${selectedTask?.updatedAt || 0}`;
|
|
1651
|
+
if (state && state.lastSignature === signature && state.unchangedNudgeCount >= 2) {
|
|
1652
|
+
decisions.push({ ...baseDecision, decision: 'none', reason: 'max-repeat-reached', renderedMessage: null });
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
// ETA-only escalation: after 2 repeated status updates without artifacts,
|
|
1656
|
+
// require artifact link or explicit blocker, else flag for reassignment
|
|
1657
|
+
const etaOnlyCount = this.countRecentEtaOnlyUpdates(messages, agent, taskId);
|
|
1658
|
+
const needsArtifact = etaOnlyCount >= 2;
|
|
1659
|
+
const intro = needsArtifact
|
|
1660
|
+
? `@${agent} @kai escalation: ${etaOnlyCount} status updates on ${taskId} with no artifact or blocker. Post artifact link or explicit blocker now, or task will be flagged for reassignment.`
|
|
1661
|
+
: tier === 1
|
|
1662
|
+
? `@${agent} system reminder: you appear idle for ${inactivityMin}m. Post a quick status update now.`
|
|
1663
|
+
: `@${agent} @kai system escalation: ${inactivityMin}m idle. Post required status format now.`;
|
|
1664
|
+
const template = needsArtifact
|
|
1665
|
+
? [
|
|
1666
|
+
`Task: ${taskId}`,
|
|
1667
|
+
'1) Artifact: <PR link, commit, or file path> (REQUIRED)',
|
|
1668
|
+
'2) Blocker: <explicit blocker if no artifact>',
|
|
1669
|
+
].join('\n')
|
|
1670
|
+
: [
|
|
1671
|
+
`Task: ${taskId}`,
|
|
1672
|
+
'1) Shipped: <artifact/commit/file>',
|
|
1673
|
+
'2) Blocker: <none or explicit blocker>',
|
|
1674
|
+
'3) Next: <next deliverable + ETA>',
|
|
1675
|
+
].join('\n');
|
|
1676
|
+
const renderedMessage = `${intro}\n${template}`;
|
|
1677
|
+
decisions.push({
|
|
1678
|
+
...baseDecision,
|
|
1679
|
+
decision: tier === 1 ? 'warn' : 'escalate',
|
|
1680
|
+
reason: 'eligible',
|
|
1681
|
+
renderedMessage,
|
|
1682
|
+
});
|
|
1683
|
+
if (dryRun) {
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
await routeMessage({
|
|
1687
|
+
from: 'system',
|
|
1688
|
+
content: renderedMessage,
|
|
1689
|
+
category: 'watchdog-alert',
|
|
1690
|
+
severity: tier === 2 ? 'warning' : 'info',
|
|
1691
|
+
taskId: taskId || undefined,
|
|
1692
|
+
mentions: tier === 2 ? [agent, 'kai'] : [agent],
|
|
1693
|
+
});
|
|
1694
|
+
const unchangedNudgeCount = state && state.lastSignature === signature
|
|
1695
|
+
? state.unchangedNudgeCount + 1
|
|
1696
|
+
: 1;
|
|
1697
|
+
this.idleNudgeState.set(agent, {
|
|
1698
|
+
lastNudgeAt: now,
|
|
1699
|
+
lastTier: tier,
|
|
1700
|
+
lastSignature: signature,
|
|
1701
|
+
unchangedNudgeCount,
|
|
1702
|
+
});
|
|
1703
|
+
nudged.push(agent);
|
|
1704
|
+
}
|
|
1705
|
+
this.idleNudgeLastDecisions = decisions;
|
|
1706
|
+
return { nudged, decisions };
|
|
1707
|
+
}
|
|
1708
|
+
getIdleNudgeDebug() {
|
|
1709
|
+
const decisionCounts = { none: 0, warn: 0, escalate: 0 };
|
|
1710
|
+
const reasonCounts = {};
|
|
1711
|
+
const laneReasonCounts = {};
|
|
1712
|
+
for (const decision of this.idleNudgeLastDecisions) {
|
|
1713
|
+
decisionCounts[decision.decision] += 1;
|
|
1714
|
+
reasonCounts[decision.reason] = (reasonCounts[decision.reason] || 0) + 1;
|
|
1715
|
+
laneReasonCounts[decision.lane.laneReason] = (laneReasonCounts[decision.lane.laneReason] || 0) + 1;
|
|
1716
|
+
}
|
|
1717
|
+
return {
|
|
1718
|
+
config: {
|
|
1719
|
+
enabled: this.idleNudgeEnabled,
|
|
1720
|
+
warnMin: this.idleNudgeWarnMin,
|
|
1721
|
+
escalateMin: this.idleNudgeEscalateMin,
|
|
1722
|
+
cooldownMin: this.idleNudgeCooldownMin,
|
|
1723
|
+
recentSuppressMin: this.idleNudgeSuppressRecentMin,
|
|
1724
|
+
shipCooldownMin: this.idleNudgeShipCooldownMin,
|
|
1725
|
+
activeTaskMaxAgeMin: this.idleNudgeActiveTaskMaxAgeMin,
|
|
1726
|
+
excluded: Array.from(this.idleNudgeExcluded.values()).sort(),
|
|
1727
|
+
},
|
|
1728
|
+
state: Array.from(this.idleNudgeState.entries()).map(([agent, s]) => ({
|
|
1729
|
+
agent,
|
|
1730
|
+
lastNudgeAt: s.lastNudgeAt,
|
|
1731
|
+
lastTier: s.lastTier,
|
|
1732
|
+
lastSignature: s.lastSignature,
|
|
1733
|
+
unchangedNudgeCount: s.unchangedNudgeCount,
|
|
1734
|
+
})),
|
|
1735
|
+
summary: {
|
|
1736
|
+
decisionCounts,
|
|
1737
|
+
reasonCounts,
|
|
1738
|
+
laneReasonCounts,
|
|
1739
|
+
},
|
|
1740
|
+
lastDecisions: this.idleNudgeLastDecisions,
|
|
1741
|
+
timestamp: Date.now(),
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Get simple summary for quick display
|
|
1746
|
+
*/
|
|
1747
|
+
async getSummary() {
|
|
1748
|
+
const health = await this.getHealth();
|
|
1749
|
+
const lines = [
|
|
1750
|
+
`🏥 **Team Health** (${new Date(health.timestamp).toLocaleTimeString()})`,
|
|
1751
|
+
'',
|
|
1752
|
+
`**Active:** ${health.activeAgents.join(', ') || 'none'}`,
|
|
1753
|
+
`**Silent >45min:** ${health.silentAgents.join(', ') || 'none'}`,
|
|
1754
|
+
];
|
|
1755
|
+
if (health.blockers.length > 0) {
|
|
1756
|
+
lines.push('');
|
|
1757
|
+
lines.push('**🚫 Blockers:**');
|
|
1758
|
+
health.blockers.slice(0, 3).forEach(b => {
|
|
1759
|
+
lines.push(`- ${b.agent}: ${b.blocker} (${b.mentionCount}x)`);
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
if (health.overlaps.length > 0) {
|
|
1763
|
+
lines.push('');
|
|
1764
|
+
lines.push('**⚠️ Overlapping work:**');
|
|
1765
|
+
health.overlaps.slice(0, 3).forEach(o => {
|
|
1766
|
+
lines.push(`- ${o.agents.join(', ')}: ${o.topic}`);
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
return lines.join('\n');
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Record health snapshot for history tracking
|
|
1773
|
+
*/
|
|
1774
|
+
async recordSnapshot() {
|
|
1775
|
+
const now = Date.now();
|
|
1776
|
+
// Only snapshot once per hour
|
|
1777
|
+
if (now - this.lastSnapshotTime < this.SNAPSHOT_INTERVAL_MS) {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const health = await this.getHealth();
|
|
1781
|
+
this.healthHistory.push(health);
|
|
1782
|
+
this.lastSnapshotTime = now;
|
|
1783
|
+
// Trim old history
|
|
1784
|
+
if (this.healthHistory.length > this.MAX_HISTORY) {
|
|
1785
|
+
this.healthHistory = this.healthHistory.slice(-this.MAX_HISTORY);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Get health history for trends
|
|
1790
|
+
*/
|
|
1791
|
+
getHealthHistory(days = 7) {
|
|
1792
|
+
const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
1793
|
+
return this.healthHistory.filter(h => h.timestamp >= cutoff);
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Track request for system health monitoring
|
|
1797
|
+
*/
|
|
1798
|
+
trackRequest(duration) {
|
|
1799
|
+
this.requestCount++;
|
|
1800
|
+
this.requestTimes.push(duration);
|
|
1801
|
+
// Keep only recent request times
|
|
1802
|
+
if (this.requestTimes.length > this.MAX_REQUEST_TIMES) {
|
|
1803
|
+
this.requestTimes = this.requestTimes.slice(-this.MAX_REQUEST_TIMES);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Track error for system health monitoring
|
|
1808
|
+
*/
|
|
1809
|
+
trackError() {
|
|
1810
|
+
this.errorCount++;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Get system health metrics
|
|
1814
|
+
*/
|
|
1815
|
+
getSystemHealth() {
|
|
1816
|
+
const uptime = Date.now() - this.systemStartTime;
|
|
1817
|
+
const uptimeHours = Math.floor(uptime / 1000 / 60 / 60);
|
|
1818
|
+
// Calculate response time percentiles
|
|
1819
|
+
const sorted = this.requestTimes.slice().sort((a, b) => a - b);
|
|
1820
|
+
const avgResponseTime = sorted.length > 0
|
|
1821
|
+
? sorted.reduce((a, b) => a + b, 0) / sorted.length
|
|
1822
|
+
: 0;
|
|
1823
|
+
const p95Index = Math.floor(sorted.length * 0.95);
|
|
1824
|
+
const p95ResponseTime = sorted[p95Index] || 0;
|
|
1825
|
+
const errorRate = this.requestCount > 0
|
|
1826
|
+
? this.errorCount / this.requestCount
|
|
1827
|
+
: 0;
|
|
1828
|
+
return {
|
|
1829
|
+
uptime,
|
|
1830
|
+
uptimeHours,
|
|
1831
|
+
memory: process.memoryUsage(),
|
|
1832
|
+
requestCount: this.requestCount,
|
|
1833
|
+
errorCount: this.errorCount,
|
|
1834
|
+
avgResponseTime: Math.round(avgResponseTime),
|
|
1835
|
+
p95ResponseTime: Math.round(p95ResponseTime),
|
|
1836
|
+
errorRate: Math.round(errorRate * 10000) / 100, // percentage
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
export const healthMonitor = new TeamHealthMonitor();
|
|
1841
|
+
//# sourceMappingURL=health.js.map
|