pi-watchdog-supervisor 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/.tmp/2026-07-08_pi_watchdog_extension_requirement.md +907 -0
- package/.tmp/2026-07-08_pi_watchdog_high_level_spec.md +192 -0
- package/.tmp/2026-07-08_pi_watchdog_task01_low_level_spec.md +234 -0
- package/.tmp/2026-07-08_pi_watchdog_task02_low_level_spec.md +158 -0
- package/.tmp/2026-07-08_pi_watchdog_task03_low_level_spec.md +175 -0
- package/.tmp/2026-07-08_pi_watchdog_task04_low_level_spec.md +151 -0
- package/.tmp/2026-07-08_pi_watchdog_task05_low_level_spec.md +116 -0
- package/.tmp/2026-07-08_pi_watchdog_task06_low_level_spec.md +69 -0
- package/.tmp/detect_approach.patch +253 -0
- package/README.md +176 -0
- package/examples/AGENTS.md +27 -0
- package/examples/watchdog-supervisor.json +12 -0
- package/package.json +39 -0
- package/prompts/watchdog-agent.md +36 -0
- package/skills/watchdog-supervisor/SKILL.md +35 -0
- package/src/checker.ts +54 -0
- package/src/collector.ts +223 -0
- package/src/commands.ts +159 -0
- package/src/config.ts +80 -0
- package/src/detector.ts +150 -0
- package/src/index.ts +94 -0
- package/src/integrations/gotgenes-subagents.ts +45 -0
- package/src/lmdebug.ts +128 -0
- package/src/normalize.ts +40 -0
- package/src/registry.ts +121 -0
- package/src/store.ts +137 -0
- package/src/tools.ts +289 -0
- package/src/types.ts +80 -0
- package/test/Screenshot_20260708_220337.png +0 -0
- package/test/checker.test.ts +118 -0
- package/test/collector.test.ts +201 -0
- package/test/config.test.ts +131 -0
- package/test/detector.test.ts +224 -0
- package/test/integration.test.ts +57 -0
- package/test/lmdebug.test.ts +114 -0
- package/test/normalize.test.ts +78 -0
- package/test/registry.test.ts +149 -0
- package/test/store.test.ts +176 -0
- package/test/tools.test.ts +315 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
applyEvent,
|
|
4
|
+
classifyKind,
|
|
5
|
+
createTargetRegistry,
|
|
6
|
+
syncFromRecords,
|
|
7
|
+
toWatchdogTarget,
|
|
8
|
+
type SubagentRecordLike,
|
|
9
|
+
} from '../src/registry.ts';
|
|
10
|
+
import type { WatchdogTarget } from '../src/types.ts';
|
|
11
|
+
|
|
12
|
+
const record = (overrides: Partial<SubagentRecordLike> = {}): SubagentRecordLike => ({
|
|
13
|
+
id: 'a1',
|
|
14
|
+
type: 'Explore',
|
|
15
|
+
description: 'explore campaign-list',
|
|
16
|
+
status: 'running',
|
|
17
|
+
toolUses: 12,
|
|
18
|
+
startedAt: 1000,
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const targetsOf = (...items: WatchdogTarget[]): Map<string, WatchdogTarget> =>
|
|
23
|
+
new Map(items.map((item) => [item.id, item]));
|
|
24
|
+
|
|
25
|
+
describe('classifyKind', () => {
|
|
26
|
+
it('detects watchdog by type (case-insensitive)', () => {
|
|
27
|
+
expect(classifyKind('Watchdog', 'anything')).toBe('watchdog');
|
|
28
|
+
expect(classifyKind('my-watchdog-agent', '')).toBe('watchdog');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('detects watchdog by description', () => {
|
|
32
|
+
expect(classifyKind('Explore', 'Watchdog supervisor for tasks')).toBe('watchdog');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('classifies regular agents as task', () => {
|
|
36
|
+
expect(classifyKind('Explore', 'fix type errors')).toBe('task');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns unknown when nothing to classify', () => {
|
|
40
|
+
expect(classifyKind(undefined, undefined)).toBe('unknown');
|
|
41
|
+
expect(classifyKind('', '')).toBe('unknown');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('toWatchdogTarget', () => {
|
|
46
|
+
it('maps record fields', () => {
|
|
47
|
+
const target = toWatchdogTarget(record());
|
|
48
|
+
expect(target).toEqual({
|
|
49
|
+
id: 'a1',
|
|
50
|
+
name: 'explore campaign-list',
|
|
51
|
+
kind: 'task',
|
|
52
|
+
status: 'running',
|
|
53
|
+
toolCallCount: 12,
|
|
54
|
+
createdAt: 1000,
|
|
55
|
+
lastActiveAt: 1000,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses completedAt as lastActiveAt when present', () => {
|
|
60
|
+
const target = toWatchdogTarget(record({ status: 'completed', completedAt: 5000 }));
|
|
61
|
+
expect(target.lastActiveAt).toBe(5000);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('applyEvent', () => {
|
|
66
|
+
it('updates status and lastActiveAt of an existing target on started', () => {
|
|
67
|
+
const existing = { ...toWatchdogTarget(record({ status: 'queued' })) };
|
|
68
|
+
const next = applyEvent(targetsOf(existing), 'subagents:started', { id: 'a1' }, 2000);
|
|
69
|
+
expect(next.get('a1')?.status).toBe('running');
|
|
70
|
+
expect(next.get('a1')?.lastActiveAt).toBe(2000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('creates a minimal target for an unknown id', () => {
|
|
74
|
+
const next = applyEvent(
|
|
75
|
+
new Map(),
|
|
76
|
+
'subagents:created',
|
|
77
|
+
{ id: 'b2', type: 'Explore', description: 'new agent' },
|
|
78
|
+
3000,
|
|
79
|
+
);
|
|
80
|
+
const created = next.get('b2');
|
|
81
|
+
expect(created?.status).toBe('queued');
|
|
82
|
+
expect(created?.kind).toBe('task');
|
|
83
|
+
expect(created?.createdAt).toBe(3000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('takes failed status from payload when valid', () => {
|
|
87
|
+
const existing = toWatchdogTarget(record());
|
|
88
|
+
const next = applyEvent(
|
|
89
|
+
targetsOf(existing),
|
|
90
|
+
'subagents:failed',
|
|
91
|
+
{ id: 'a1', status: 'aborted' },
|
|
92
|
+
4000,
|
|
93
|
+
);
|
|
94
|
+
expect(next.get('a1')?.status).toBe('aborted');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('defaults failed status to error when payload has no valid status', () => {
|
|
98
|
+
const existing = toWatchdogTarget(record());
|
|
99
|
+
const next = applyEvent(targetsOf(existing), 'subagents:failed', { id: 'a1' }, 4000);
|
|
100
|
+
expect(next.get('a1')?.status).toBe('error');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('only touches lastActiveAt on steered and compacted', () => {
|
|
104
|
+
const existing = toWatchdogTarget(record());
|
|
105
|
+
for (const channel of ['subagents:steered', 'subagents:compacted']) {
|
|
106
|
+
const next = applyEvent(targetsOf(existing), channel, { id: 'a1' }, 6000);
|
|
107
|
+
expect(next.get('a1')?.status).toBe('running');
|
|
108
|
+
expect(next.get('a1')?.lastActiveAt).toBe(6000);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ignores payloads without a string id', () => {
|
|
113
|
+
const existing = toWatchdogTarget(record());
|
|
114
|
+
const targets = targetsOf(existing);
|
|
115
|
+
expect(applyEvent(targets, 'subagents:started', {}, 2000)).toBe(targets);
|
|
116
|
+
expect(applyEvent(targets, 'subagents:started', null, 2000)).toBe(targets);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('syncFromRecords', () => {
|
|
121
|
+
it('adds new records as targets', () => {
|
|
122
|
+
const next = syncFromRecords(new Map(), [record()]);
|
|
123
|
+
expect(next.get('a1')?.name).toBe('explore campaign-list');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('keeps the newer lastActiveAt on existing targets', () => {
|
|
127
|
+
const existing = { ...toWatchdogTarget(record()), lastActiveAt: 9000 };
|
|
128
|
+
const next = syncFromRecords(targetsOf(existing), [record({ toolUses: 20 })]);
|
|
129
|
+
expect(next.get('a1')?.lastActiveAt).toBe(9000);
|
|
130
|
+
expect(next.get('a1')?.toolCallCount).toBe(20);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('keeps targets missing from the record list', () => {
|
|
134
|
+
const existing = toWatchdogTarget(record({ id: 'gone' }));
|
|
135
|
+
const next = syncFromRecords(targetsOf(existing), []);
|
|
136
|
+
expect(next.get('gone')).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('createTargetRegistry', () => {
|
|
141
|
+
it('accumulates events and record syncs', () => {
|
|
142
|
+
const registry = createTargetRegistry();
|
|
143
|
+
registry.applyEvent('subagents:created', { id: 'a1', type: 'watchdog' }, 1000);
|
|
144
|
+
registry.syncFromRecords([record({ id: 'b2' })]);
|
|
145
|
+
const ids = registry.list().map((target) => target.id);
|
|
146
|
+
expect(ids).toContain('a1');
|
|
147
|
+
expect(ids).toContain('b2');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { getOrCreateStore, resetStoreForTest } from '../src/store.ts';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
resetStoreForTest();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const event = (overrides: Record<string, unknown> = {}) => ({
|
|
9
|
+
at: 1000,
|
|
10
|
+
type: 'tool_call' as const,
|
|
11
|
+
summary: 'bash: rg "X" src',
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('getOrCreateStore', () => {
|
|
16
|
+
it('returns the same instance across calls (globalThis shared)', () => {
|
|
17
|
+
const a = getOrCreateStore(10);
|
|
18
|
+
const b = getOrCreateStore(10);
|
|
19
|
+
expect(a).toBe(b);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('registerChild / getChild', () => {
|
|
24
|
+
it('registers and resolves a child session', () => {
|
|
25
|
+
const store = getOrCreateStore(10);
|
|
26
|
+
store.registerChild('sess-1', 'parent-1');
|
|
27
|
+
expect(store.getChild('sess-1')).toEqual({ parentSessionId: 'parent-1' });
|
|
28
|
+
expect(store.getChild('sess-x')).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('appendEvent / getEvents', () => {
|
|
33
|
+
it('assigns sequential ids and targetId', () => {
|
|
34
|
+
const store = getOrCreateStore(10);
|
|
35
|
+
store.appendEvent('sess-1', event());
|
|
36
|
+
store.appendEvent('sess-1', event({ at: 2000 }));
|
|
37
|
+
const events = store.getEvents('sess-1');
|
|
38
|
+
expect(events).toHaveLength(2);
|
|
39
|
+
expect(events[0]?.id).toBe('sess-1-1');
|
|
40
|
+
expect(events[1]?.id).toBe('sess-1-2');
|
|
41
|
+
expect(events[0]?.targetId).toBe('sess-1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('drops oldest events beyond maxEventsPerAgent (ring buffer)', () => {
|
|
45
|
+
const store = getOrCreateStore(3);
|
|
46
|
+
for (let i = 1; i <= 5; i += 1) {
|
|
47
|
+
store.appendEvent('sess-1', event({ at: i }));
|
|
48
|
+
}
|
|
49
|
+
const events = store.getEvents('sess-1');
|
|
50
|
+
expect(events).toHaveLength(3);
|
|
51
|
+
expect(events[0]?.at).toBe(3);
|
|
52
|
+
expect(events[2]?.at).toBe(5);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns the latest limit entries in chronological order', () => {
|
|
56
|
+
const store = getOrCreateStore(10);
|
|
57
|
+
for (let i = 1; i <= 4; i += 1) {
|
|
58
|
+
store.appendEvent('sess-1', event({ at: i }));
|
|
59
|
+
}
|
|
60
|
+
const events = store.getEvents('sess-1', 2);
|
|
61
|
+
expect(events.map((e) => e.at)).toEqual([3, 4]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns empty array for unknown target', () => {
|
|
65
|
+
const store = getOrCreateStore(10);
|
|
66
|
+
expect(store.getEvents('nope')).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('linkAgent / resolveTargetKey', () => {
|
|
71
|
+
it('resolves both agentId and sessionId to the session key', () => {
|
|
72
|
+
const store = getOrCreateStore(10);
|
|
73
|
+
store.registerChild('sess-1', 'parent-1');
|
|
74
|
+
store.linkAgent('agent-1', 'sess-1');
|
|
75
|
+
expect(store.resolveTargetKey('agent-1')).toBe('sess-1');
|
|
76
|
+
expect(store.resolveTargetKey('sess-1')).toBe('sess-1');
|
|
77
|
+
expect(store.resolveTargetKey('unknown')).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('getLastAlert / recordAlert', () => {
|
|
82
|
+
it('round-trips the last alert per target', () => {
|
|
83
|
+
const store = getOrCreateStore(10);
|
|
84
|
+
expect(store.getLastAlert('sess-1')).toBeUndefined();
|
|
85
|
+
store.recordAlert('sess-1', 'key-1', 5000);
|
|
86
|
+
expect(store.getLastAlert('sess-1')).toEqual({ at: 5000, evidenceKey: 'key-1' });
|
|
87
|
+
store.recordAlert('sess-1', 'key-2', 6000);
|
|
88
|
+
expect(store.getLastAlert('sess-1')).toEqual({ at: 6000, evidenceKey: 'key-2' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is shared across getOrCreateStore calls', () => {
|
|
92
|
+
getOrCreateStore(10).recordAlert('sess-1', 'key-1', 5000);
|
|
93
|
+
expect(getOrCreateStore(10).getLastAlert('sess-1')).toEqual({ at: 5000, evidenceKey: 'key-1' });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('paused state (shared)', () => {
|
|
98
|
+
it('starts unpaused and toggles via setPaused', () => {
|
|
99
|
+
const store = getOrCreateStore(10);
|
|
100
|
+
expect(store.isPaused()).toBe(false);
|
|
101
|
+
store.setPaused(true);
|
|
102
|
+
expect(store.isPaused()).toBe(true);
|
|
103
|
+
store.setPaused(false);
|
|
104
|
+
expect(store.isPaused()).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('is shared across getOrCreateStore calls', () => {
|
|
108
|
+
getOrCreateStore(10).setPaused(true);
|
|
109
|
+
expect(getOrCreateStore(10).isPaused()).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('rescue message override (shared)', () => {
|
|
114
|
+
it('starts undefined and round-trips', () => {
|
|
115
|
+
const store = getOrCreateStore(10);
|
|
116
|
+
expect(store.getRescueMessage()).toBeUndefined();
|
|
117
|
+
store.setRescueMessage('custom message');
|
|
118
|
+
expect(store.getRescueMessage()).toBe('custom message');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('config override (shared)', () => {
|
|
123
|
+
it('starts empty and merges successive sets', () => {
|
|
124
|
+
const store = getOrCreateStore(10);
|
|
125
|
+
expect(store.getConfigOverride()).toEqual({});
|
|
126
|
+
store.setConfigOverride({ llmRepeatThreshold: 5 });
|
|
127
|
+
store.setConfigOverride({ cooldownSec: 120 });
|
|
128
|
+
expect(store.getConfigOverride()).toEqual({ llmRepeatThreshold: 5, cooldownSec: 120 });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('alert sink', () => {
|
|
133
|
+
it('returns false when no sink is registered', () => {
|
|
134
|
+
const store = getOrCreateStore(10);
|
|
135
|
+
expect(store.alert('hello', 'warning')).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('delivers message and severity to the registered sink', () => {
|
|
139
|
+
const store = getOrCreateStore(10);
|
|
140
|
+
const received: Array<[string, string]> = [];
|
|
141
|
+
store.setAlertSink((message, severity) => {
|
|
142
|
+
received.push([message, severity]);
|
|
143
|
+
});
|
|
144
|
+
expect(store.alert('hello', 'critical')).toBe(true);
|
|
145
|
+
expect(received).toEqual([['hello', 'critical']]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('resolveAgentId', () => {
|
|
150
|
+
it('maps a sessionId back to its linked agentId and passes agentIds through', () => {
|
|
151
|
+
const store = getOrCreateStore(10);
|
|
152
|
+
store.registerChild('sess-1');
|
|
153
|
+
store.linkAgent('agent-1', 'sess-1');
|
|
154
|
+
expect(store.resolveAgentId('sess-1')).toBe('agent-1');
|
|
155
|
+
expect(store.resolveAgentId('agent-1')).toBe('agent-1');
|
|
156
|
+
expect(store.resolveAgentId('unknown')).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('FIFO agent linking', () => {
|
|
161
|
+
it('pairs session-created and started events in order', () => {
|
|
162
|
+
const store = getOrCreateStore(10);
|
|
163
|
+
store.registerChild('sess-1');
|
|
164
|
+
store.registerChild('sess-2');
|
|
165
|
+
store.linkNextAgent('agent-1');
|
|
166
|
+
store.linkNextAgent('agent-2');
|
|
167
|
+
expect(store.resolveTargetKey('agent-1')).toBe('sess-1');
|
|
168
|
+
expect(store.resolveTargetKey('agent-2')).toBe('sess-2');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('ignores linkNextAgent when no unlinked child is pending', () => {
|
|
172
|
+
const store = getOrCreateStore(10);
|
|
173
|
+
store.linkNextAgent('agent-1');
|
|
174
|
+
expect(store.resolveTargetKey('agent-1')).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_CONFIG } from '../src/config.ts';
|
|
3
|
+
import { createTargetRegistry } from '../src/registry.ts';
|
|
4
|
+
import { getOrCreateStore, resetStoreForTest } from '../src/store.ts';
|
|
5
|
+
import type { SubagentsIntegration } from '../src/integrations/gotgenes-subagents.ts';
|
|
6
|
+
import {
|
|
7
|
+
executeAlertMain,
|
|
8
|
+
executeConfig,
|
|
9
|
+
executeDetectStuck,
|
|
10
|
+
executeListTargets,
|
|
11
|
+
executeReadEvents,
|
|
12
|
+
executeSteerSubagent,
|
|
13
|
+
formatAlert,
|
|
14
|
+
type ToolDeps,
|
|
15
|
+
} from '../src/tools.ts';
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetStoreForTest();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const NOW = 1_000_000;
|
|
22
|
+
|
|
23
|
+
const record = (overrides: Record<string, unknown> = {}) => ({
|
|
24
|
+
id: 'agent-1',
|
|
25
|
+
type: 'Explore',
|
|
26
|
+
description: 'explore stuff',
|
|
27
|
+
status: 'running' as const,
|
|
28
|
+
toolUses: 3,
|
|
29
|
+
startedAt: NOW - 10_000,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const makeDeps = (overrides: Partial<ToolDeps> = {}): ToolDeps => ({
|
|
34
|
+
store: getOrCreateStore(50),
|
|
35
|
+
registry: createTargetRegistry(),
|
|
36
|
+
getIntegration: async () => ({ available: false, reason: 'test' }),
|
|
37
|
+
baseConfig: DEFAULT_CONFIG,
|
|
38
|
+
now: () => NOW,
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const availableIntegration = (
|
|
43
|
+
records: ReturnType<typeof record>[],
|
|
44
|
+
steered: Array<[string, string]> = [],
|
|
45
|
+
): SubagentsIntegration => ({
|
|
46
|
+
available: true,
|
|
47
|
+
listAgents: () => records,
|
|
48
|
+
steer: async (id, message) => {
|
|
49
|
+
steered.push([id, message]);
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Feed N repeated llm_output events so the detector flags repeated_llm_output
|
|
55
|
+
const feedRepeatedResults = (
|
|
56
|
+
deps: ToolDeps,
|
|
57
|
+
sessionId: string,
|
|
58
|
+
times: number,
|
|
59
|
+
baseAt = NOW - times * 1000,
|
|
60
|
+
) => {
|
|
61
|
+
deps.store.registerChild(sessionId);
|
|
62
|
+
for (let i = 0; i < times; i += 1) {
|
|
63
|
+
deps.store.appendEvent(sessionId, {
|
|
64
|
+
at: baseAt + i * 1000,
|
|
65
|
+
type: 'llm_output',
|
|
66
|
+
summary: 'llm_output: same body',
|
|
67
|
+
commandKey: 'llm_output',
|
|
68
|
+
outputHash: 'hash-same',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe('executeListTargets', () => {
|
|
74
|
+
it('reports unavailable integration', async () => {
|
|
75
|
+
const text = await executeListTargets(makeDeps(), {});
|
|
76
|
+
expect(text).toContain('unavailable');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('filters completed targets by default and includes them on request', async () => {
|
|
80
|
+
const deps = makeDeps({
|
|
81
|
+
getIntegration: async () =>
|
|
82
|
+
availableIntegration([record(), record({ id: 'agent-2', status: 'completed' })]),
|
|
83
|
+
});
|
|
84
|
+
const defaultText = await executeListTargets(deps, {});
|
|
85
|
+
const defaultParsed = JSON.parse(defaultText);
|
|
86
|
+
expect(defaultParsed.targets).toHaveLength(1);
|
|
87
|
+
expect(defaultParsed.targets[0].id).toBe('agent-1');
|
|
88
|
+
|
|
89
|
+
const allText = await executeListTargets(deps, { includeCompleted: true });
|
|
90
|
+
expect(JSON.parse(allText).targets).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('marks likelyStuck from buffered events', async () => {
|
|
94
|
+
const deps = makeDeps({
|
|
95
|
+
getIntegration: async () => availableIntegration([record()]),
|
|
96
|
+
});
|
|
97
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
98
|
+
deps.store.linkAgent('agent-1', 'sess-1');
|
|
99
|
+
const parsed = JSON.parse(await executeListTargets(deps, {}));
|
|
100
|
+
expect(parsed.targets[0].likelyStuck).toBe(true);
|
|
101
|
+
expect(parsed.targets[0].repeatedMessageCount).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('executeReadEvents', () => {
|
|
106
|
+
it('returns an error message for an unknown target', () => {
|
|
107
|
+
expect(executeReadEvents(makeDeps(), { targetId: 'nope' })).toContain('Unknown target');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns recent events as JSON', () => {
|
|
111
|
+
const deps = makeDeps();
|
|
112
|
+
feedRepeatedResults(deps, 'sess-1', 2);
|
|
113
|
+
const parsed = JSON.parse(executeReadEvents(deps, { targetId: 'sess-1' }));
|
|
114
|
+
expect(parsed.targetId).toBe('sess-1');
|
|
115
|
+
expect(parsed.events).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('executeDetectStuck', () => {
|
|
120
|
+
it('includes analysis and the suggested rescue message', () => {
|
|
121
|
+
const deps = makeDeps();
|
|
122
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
123
|
+
const parsed = JSON.parse(executeDetectStuck(deps, { targetId: 'sess-1' }));
|
|
124
|
+
expect(parsed.likelyStuck).toBe(true);
|
|
125
|
+
expect(parsed.evidence.some((e: { type: string }) => e.type === 'repeated_llm_output')).toBe(true);
|
|
126
|
+
expect(parsed.suggestedRescueMessage).toBe(DEFAULT_CONFIG.rescueMessage);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('prefers the shared rescue message override', () => {
|
|
130
|
+
const deps = makeDeps();
|
|
131
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
132
|
+
deps.store.setRescueMessage('custom rescue');
|
|
133
|
+
const parsed = JSON.parse(executeDetectStuck(deps, { targetId: 'sess-1' }));
|
|
134
|
+
expect(parsed.suggestedRescueMessage).toBe('custom rescue');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('executeAlertMain', () => {
|
|
139
|
+
it('suppresses alerts while paused', () => {
|
|
140
|
+
const deps = makeDeps();
|
|
141
|
+
deps.store.setPaused(true);
|
|
142
|
+
expect(executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' })).toContain('paused');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('delivers the alert to the sink and records it', () => {
|
|
146
|
+
const deps = makeDeps();
|
|
147
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
148
|
+
const received: string[] = [];
|
|
149
|
+
deps.store.setAlertSink((message) => {
|
|
150
|
+
received.push(message);
|
|
151
|
+
});
|
|
152
|
+
const result = executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' });
|
|
153
|
+
expect(result).toContain('alert sent');
|
|
154
|
+
expect(received).toHaveLength(1);
|
|
155
|
+
expect(received[0]).toContain('[Watchdog Alert]');
|
|
156
|
+
expect(deps.store.getLastAlert('sess-1')).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('suppresses a repeat alert for the same evidence within a positive cooldown', () => {
|
|
160
|
+
const deps = makeDeps();
|
|
161
|
+
deps.store.setConfigOverride({ cooldownSec: 60 });
|
|
162
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
163
|
+
deps.store.setAlertSink(() => {});
|
|
164
|
+
executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' });
|
|
165
|
+
// the loop keeps repeating after the alert, but the cooldown blocks a re-alert
|
|
166
|
+
feedRepeatedResults(deps, 'sess-1', 3, NOW + 1000);
|
|
167
|
+
const second = executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' });
|
|
168
|
+
expect(second).toContain('cooldown');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('re-alerts with the default no-cooldown (0) once the loop repeats after the alert', () => {
|
|
172
|
+
const deps = makeDeps();
|
|
173
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
174
|
+
deps.store.setAlertSink(() => {});
|
|
175
|
+
executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' });
|
|
176
|
+
// counter resets at the alert; fresh repeats after it re-trigger
|
|
177
|
+
feedRepeatedResults(deps, 'sess-1', 3, NOW + 1000);
|
|
178
|
+
const second = executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' });
|
|
179
|
+
expect(second).toContain('alert sent to main session');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('reports a missing sink as an error', () => {
|
|
183
|
+
const deps = makeDeps();
|
|
184
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
185
|
+
expect(executeAlertMain(deps, { targetId: 'sess-1', message: 'stuck!' })).toContain('no alert sink');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('executeSteerSubagent', () => {
|
|
190
|
+
it('defaults to dry-run with the effective rescue message', async () => {
|
|
191
|
+
const deps = makeDeps();
|
|
192
|
+
deps.store.registerChild('sess-1');
|
|
193
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1' });
|
|
194
|
+
expect(result).toContain('would steer');
|
|
195
|
+
expect(result).toContain(DEFAULT_CONFIG.rescueMessage);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('refuses a real steer in main_only mode', async () => {
|
|
199
|
+
const deps = makeDeps();
|
|
200
|
+
deps.store.registerChild('sess-1');
|
|
201
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1', dryRun: false });
|
|
202
|
+
expect(result).toContain('main_only');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('performs a real steer when alertMode allows it', async () => {
|
|
206
|
+
const steered: Array<[string, string]> = [];
|
|
207
|
+
const deps = makeDeps({
|
|
208
|
+
baseConfig: { ...DEFAULT_CONFIG, alertMode: 'both' },
|
|
209
|
+
getIntegration: async () => availableIntegration([record()], steered),
|
|
210
|
+
});
|
|
211
|
+
deps.store.registerChild('sess-1');
|
|
212
|
+
deps.store.linkAgent('agent-1', 'sess-1');
|
|
213
|
+
const result = await executeSteerSubagent(deps, {
|
|
214
|
+
targetId: 'sess-1',
|
|
215
|
+
message: 'wake up',
|
|
216
|
+
dryRun: false,
|
|
217
|
+
});
|
|
218
|
+
expect(result).toContain('steered');
|
|
219
|
+
expect(steered).toEqual([['agent-1', 'wake up']]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('steers without an explicit dryRun when steerDryRunDefault is false', async () => {
|
|
223
|
+
const steered: Array<[string, string]> = [];
|
|
224
|
+
const deps = makeDeps({
|
|
225
|
+
baseConfig: { ...DEFAULT_CONFIG, alertMode: 'both', steerDryRunDefault: false },
|
|
226
|
+
getIntegration: async () => availableIntegration([record()], steered),
|
|
227
|
+
});
|
|
228
|
+
deps.store.registerChild('sess-1');
|
|
229
|
+
deps.store.linkAgent('agent-1', 'sess-1');
|
|
230
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1', message: 'wake up' });
|
|
231
|
+
expect(result).toContain('steered');
|
|
232
|
+
expect(steered).toEqual([['agent-1', 'wake up']]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('keeps the dry-run default when steerDryRunDefault is null', async () => {
|
|
236
|
+
const deps = makeDeps({
|
|
237
|
+
baseConfig: { ...DEFAULT_CONFIG, alertMode: 'both', steerDryRunDefault: null },
|
|
238
|
+
});
|
|
239
|
+
deps.store.registerChild('sess-1');
|
|
240
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1' });
|
|
241
|
+
expect(result).toContain('would steer');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('keeps an explicit dryRun: true even when steerDryRunDefault is false', async () => {
|
|
245
|
+
const deps = makeDeps({
|
|
246
|
+
baseConfig: { ...DEFAULT_CONFIG, alertMode: 'both', steerDryRunDefault: false },
|
|
247
|
+
});
|
|
248
|
+
deps.store.registerChild('sess-1');
|
|
249
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1', dryRun: true });
|
|
250
|
+
expect(result).toContain('would steer');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('still refuses in main_only mode when steerDryRunDefault is false', async () => {
|
|
254
|
+
const deps = makeDeps({
|
|
255
|
+
baseConfig: { ...DEFAULT_CONFIG, steerDryRunDefault: false },
|
|
256
|
+
});
|
|
257
|
+
deps.store.registerChild('sess-1');
|
|
258
|
+
const result = await executeSteerSubagent(deps, { targetId: 'sess-1' });
|
|
259
|
+
expect(result).toContain('main_only');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('executeConfig', () => {
|
|
264
|
+
it('returns the effective config including overrides', () => {
|
|
265
|
+
const deps = makeDeps();
|
|
266
|
+
deps.store.setConfigOverride({ llmRepeatThreshold: 7 });
|
|
267
|
+
const parsed = JSON.parse(executeConfig(deps, { action: 'get' }));
|
|
268
|
+
expect(parsed.llmRepeatThreshold).toBe(7);
|
|
269
|
+
expect(parsed.alertMode).toBe(DEFAULT_CONFIG.alertMode);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('applies only known fields on set', () => {
|
|
273
|
+
const deps = makeDeps();
|
|
274
|
+
executeConfig(deps, {
|
|
275
|
+
action: 'set',
|
|
276
|
+
config: { cooldownSec: 90, bogus: true } as never,
|
|
277
|
+
});
|
|
278
|
+
expect(deps.store.getConfigOverride()).toEqual({ cooldownSec: 90 });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('formatAlert', () => {
|
|
283
|
+
it('renders the requirement §8.2 template', () => {
|
|
284
|
+
const deps = makeDeps();
|
|
285
|
+
feedRepeatedResults(deps, 'sess-1', 3);
|
|
286
|
+
const alert = formatAlert(
|
|
287
|
+
'sess-1',
|
|
288
|
+
JSON.parse(executeDetectStuck(deps, { targetId: 'sess-1' })),
|
|
289
|
+
deps.store.getEvents('sess-1'),
|
|
290
|
+
'stuck!',
|
|
291
|
+
DEFAULT_CONFIG.rescueMessage,
|
|
292
|
+
);
|
|
293
|
+
expect(alert).toBe(
|
|
294
|
+
[
|
|
295
|
+
'[Watchdog Alert]',
|
|
296
|
+
'',
|
|
297
|
+
'Target: sess-1',
|
|
298
|
+
'Status: likely stuck',
|
|
299
|
+
'Confidence: medium',
|
|
300
|
+
'',
|
|
301
|
+
'Evidence:',
|
|
302
|
+
'- llm message repeated 3 times: same LLM output body (hash hash-sam)',
|
|
303
|
+
'',
|
|
304
|
+
'Note:',
|
|
305
|
+
'stuck!',
|
|
306
|
+
'',
|
|
307
|
+
'Last command:',
|
|
308
|
+
'llm_output',
|
|
309
|
+
'',
|
|
310
|
+
'Suggested rescue:',
|
|
311
|
+
DEFAULT_CONFIG.rescueMessage,
|
|
312
|
+
].join('\n'),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
13
|
+
}
|