@doingdev/opencode-claude-manager-plugin 0.1.58 → 0.1.59
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/dist/plugin/claude-manager.plugin.js +52 -24
- package/dist/plugin/service-factory.d.ts +4 -4
- package/dist/plugin/service-factory.js +14 -18
- package/dist/src/plugin/claude-manager.plugin.js +52 -24
- package/dist/src/plugin/service-factory.d.ts +4 -4
- package/dist/src/plugin/service-factory.js +14 -18
- package/dist/src/state/team-state-store.d.ts +0 -3
- package/dist/src/state/team-state-store.js +0 -22
- package/dist/state/team-state-store.d.ts +0 -3
- package/dist/state/team-state-store.js +0 -22
- package/dist/test/cto-active-team.test.js +176 -29
- package/dist/test/report-claude-event.test.js +16 -12
- package/dist/test/team-state-store.test.js +0 -18
- package/package.json +1 -1
|
@@ -3,12 +3,46 @@ import { managerPromptRegistry } from '../prompts/registry.js';
|
|
|
3
3
|
import { isEngineerName } from '../team/roster.js';
|
|
4
4
|
import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
|
|
5
5
|
import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
|
|
7
7
|
const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
|
|
8
8
|
const MODE_ENUM = ['explore', 'implement', 'verify'];
|
|
9
|
-
export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
9
|
+
export const ClaudeManagerPlugin = async ({ worktree, client }) => {
|
|
10
10
|
const services = getOrCreatePluginServices(worktree);
|
|
11
11
|
await services.approvalManager.loadPersistedPolicy();
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the team ID for a brand-new engineer wrapper session.
|
|
14
|
+
*
|
|
15
|
+
* 1. Walk the cached parentID chain (populated by session.created events).
|
|
16
|
+
* 2. On a cache miss, attempt a live client.session.get() lookup and cache
|
|
17
|
+
* whatever parentID the SDK returns.
|
|
18
|
+
* 3. Fall back to the orphan sentinel (sessionID itself) only when both
|
|
19
|
+
* cache and live lookup come up empty.
|
|
20
|
+
*/
|
|
21
|
+
async function resolveTeamId(sessionID) {
|
|
22
|
+
let current = sessionID;
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
while (current && !seen.has(current)) {
|
|
25
|
+
seen.add(current);
|
|
26
|
+
const team = getSessionTeam(current);
|
|
27
|
+
if (team !== undefined)
|
|
28
|
+
return team;
|
|
29
|
+
// Cache miss on this node's parent: try the live SDK.
|
|
30
|
+
if (client && !getParentSessionId(current)) {
|
|
31
|
+
try {
|
|
32
|
+
const result = await client.session.get({ path: { id: current } });
|
|
33
|
+
const parentID = result.data?.parentID;
|
|
34
|
+
if (parentID) {
|
|
35
|
+
registerParentSession(current, parentID);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Network / auth failure — let the walk continue to orphan.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
current = getParentSessionId(current);
|
|
43
|
+
}
|
|
44
|
+
return sessionID;
|
|
45
|
+
}
|
|
12
46
|
return {
|
|
13
47
|
config: async (config) => {
|
|
14
48
|
config.agent ??= {};
|
|
@@ -23,15 +57,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
23
57
|
},
|
|
24
58
|
'chat.message': async (input) => {
|
|
25
59
|
if (input.agent === AGENT_CTO) {
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
const activeTeamId = persistedTeamId ?? input.sessionID;
|
|
30
|
-
setActiveTeamSession(worktree, activeTeamId);
|
|
31
|
-
if (!persistedTeamId) {
|
|
32
|
-
// First CTO session for this worktree — persist this session as active team.
|
|
33
|
-
await setPersistedActiveTeam(worktree, activeTeamId);
|
|
34
|
-
}
|
|
60
|
+
// Each CTO session ID is its own team. The session ID is the durable
|
|
61
|
+
// identity: no worktree-global active-team state.
|
|
62
|
+
registerSessionTeam(input.sessionID, input.sessionID);
|
|
35
63
|
return;
|
|
36
64
|
}
|
|
37
65
|
if (input.agent && isEngineerAgent(input.agent)) {
|
|
@@ -39,7 +67,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
39
67
|
const existing = getWrapperSessionMapping(worktree, input.sessionID);
|
|
40
68
|
const persisted = existing ??
|
|
41
69
|
(await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
|
|
42
|
-
const teamId = persisted?.teamId ?? (await resolveTeamId(
|
|
70
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
|
|
43
71
|
setWrapperSessionMapping(worktree, input.sessionID, {
|
|
44
72
|
teamId,
|
|
45
73
|
workerName: engineer,
|
|
@@ -47,6 +75,14 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
47
75
|
await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
|
|
48
76
|
}
|
|
49
77
|
},
|
|
78
|
+
event: async ({ event: sdkEvent }) => {
|
|
79
|
+
if (sdkEvent.type === 'session.created') {
|
|
80
|
+
const session = sdkEvent.properties.info;
|
|
81
|
+
if (session.parentID) {
|
|
82
|
+
registerParentSession(session.id, session.parentID);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
50
86
|
'experimental.chat.system.transform': async (input, output) => {
|
|
51
87
|
if (!input.sessionID) {
|
|
52
88
|
return;
|
|
@@ -86,7 +122,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
86
122
|
const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
|
|
87
123
|
const persisted = existing ??
|
|
88
124
|
(await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
|
|
89
|
-
const teamId = persisted?.teamId ?? (await resolveTeamId(context.
|
|
125
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
|
|
90
126
|
setWrapperSessionMapping(context.worktree, context.sessionID, {
|
|
91
127
|
teamId,
|
|
92
128
|
workerName: engineer,
|
|
@@ -117,7 +153,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
117
153
|
teamId: tool.schema.string().optional(),
|
|
118
154
|
},
|
|
119
155
|
async execute(args, context) {
|
|
120
|
-
const teamId = args.teamId ??
|
|
156
|
+
const teamId = args.teamId ?? context.sessionID;
|
|
121
157
|
annotateToolRun(context, 'Reading team status', {
|
|
122
158
|
teamId,
|
|
123
159
|
});
|
|
@@ -134,7 +170,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
134
170
|
model: tool.schema.enum(MODEL_ENUM).optional(),
|
|
135
171
|
},
|
|
136
172
|
async execute(args, context) {
|
|
137
|
-
const teamId =
|
|
173
|
+
const teamId = context.sessionID;
|
|
138
174
|
// Pre-determine engineers for event labeling (using orchestrator selection logic)
|
|
139
175
|
const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
|
|
140
176
|
annotateToolRun(context, 'Running dual-engineer plan synthesis', {
|
|
@@ -178,7 +214,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
178
214
|
clearHistory: tool.schema.boolean().optional(),
|
|
179
215
|
},
|
|
180
216
|
async execute(args, context) {
|
|
181
|
-
const teamId =
|
|
217
|
+
const teamId = context.sessionID;
|
|
182
218
|
annotateToolRun(context, `Resetting ${args.engineer}`, {
|
|
183
219
|
teamId,
|
|
184
220
|
clearSession: args.clearSession,
|
|
@@ -456,14 +492,6 @@ export function isEngineerAgent(agentId) {
|
|
|
456
492
|
const normalized = normalizeAgentId(agentId);
|
|
457
493
|
return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
|
|
458
494
|
}
|
|
459
|
-
/**
|
|
460
|
-
* Resolves the team ID for an engineer session.
|
|
461
|
-
* Reads the persisted active team first (survives process restarts), then
|
|
462
|
-
* falls back to the in-memory registry, then to the raw session ID as a last resort.
|
|
463
|
-
*/
|
|
464
|
-
async function resolveTeamId(worktree, sessionID) {
|
|
465
|
-
return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
|
|
466
|
-
}
|
|
467
495
|
function formatToolDescription(toolName, toolArgs) {
|
|
468
496
|
if (!toolArgs || typeof toolArgs !== 'object')
|
|
469
497
|
return undefined;
|
|
@@ -14,10 +14,10 @@ interface ClaudeManagerPluginServices {
|
|
|
14
14
|
}
|
|
15
15
|
export declare function getOrCreatePluginServices(worktree: string): ClaudeManagerPluginServices;
|
|
16
16
|
export declare function clearPluginServices(): void;
|
|
17
|
-
export declare function
|
|
18
|
-
export declare function
|
|
19
|
-
export declare function
|
|
20
|
-
export declare function
|
|
17
|
+
export declare function registerParentSession(childId: string, parentId: string): void;
|
|
18
|
+
export declare function getParentSessionId(childId: string): string | undefined;
|
|
19
|
+
export declare function registerSessionTeam(sessionId: string, teamId: string): void;
|
|
20
|
+
export declare function getSessionTeam(sessionId: string): string | undefined;
|
|
21
21
|
export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
|
|
22
22
|
teamId: string;
|
|
23
23
|
workerName: EngineerName;
|
|
@@ -10,8 +10,11 @@ import { TeamStateStore } from '../state/team-state-store.js';
|
|
|
10
10
|
import { TranscriptStore } from '../state/transcript-store.js';
|
|
11
11
|
import { buildWorkerCapabilities } from './agents/browser-qa.js';
|
|
12
12
|
const serviceRegistry = new Map();
|
|
13
|
-
const activeTeamRegistry = new Map();
|
|
14
13
|
const wrapperSessionRegistry = new Map();
|
|
14
|
+
/** childSessionId → parentSessionId — populated from session.created events */
|
|
15
|
+
const parentSessionRegistry = new Map();
|
|
16
|
+
/** ctoSessionId → teamId — populated when a CTO chat.message fires */
|
|
17
|
+
const sessionTeamRegistry = new Map();
|
|
15
18
|
export function getOrCreatePluginServices(worktree) {
|
|
16
19
|
const existing = serviceRegistry.get(worktree);
|
|
17
20
|
if (existing) {
|
|
@@ -40,28 +43,21 @@ export function getOrCreatePluginServices(worktree) {
|
|
|
40
43
|
}
|
|
41
44
|
export function clearPluginServices() {
|
|
42
45
|
serviceRegistry.clear();
|
|
43
|
-
activeTeamRegistry.clear();
|
|
44
46
|
wrapperSessionRegistry.clear();
|
|
47
|
+
parentSessionRegistry.clear();
|
|
48
|
+
sessionTeamRegistry.clear();
|
|
45
49
|
}
|
|
46
|
-
export function
|
|
47
|
-
|
|
50
|
+
export function registerParentSession(childId, parentId) {
|
|
51
|
+
parentSessionRegistry.set(childId, parentId);
|
|
48
52
|
}
|
|
49
|
-
export function
|
|
50
|
-
return
|
|
53
|
+
export function getParentSessionId(childId) {
|
|
54
|
+
return parentSessionRegistry.get(childId);
|
|
51
55
|
}
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
if (!services) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
return services.teamStore.getActiveTeam(worktree);
|
|
56
|
+
export function registerSessionTeam(sessionId, teamId) {
|
|
57
|
+
sessionTeamRegistry.set(sessionId, teamId);
|
|
58
58
|
}
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
if (!services) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
await services.teamStore.setActiveTeam(worktree, teamId);
|
|
59
|
+
export function getSessionTeam(sessionId) {
|
|
60
|
+
return sessionTeamRegistry.get(sessionId);
|
|
65
61
|
}
|
|
66
62
|
export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
|
|
67
63
|
wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
|
|
@@ -3,12 +3,46 @@ import { managerPromptRegistry } from '../prompts/registry.js';
|
|
|
3
3
|
import { isEngineerName } from '../team/roster.js';
|
|
4
4
|
import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
|
|
5
5
|
import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import { getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
|
|
7
7
|
const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
|
|
8
8
|
const MODE_ENUM = ['explore', 'implement', 'verify'];
|
|
9
|
-
export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
9
|
+
export const ClaudeManagerPlugin = async ({ worktree, client }) => {
|
|
10
10
|
const services = getOrCreatePluginServices(worktree);
|
|
11
11
|
await services.approvalManager.loadPersistedPolicy();
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the team ID for a brand-new engineer wrapper session.
|
|
14
|
+
*
|
|
15
|
+
* 1. Walk the cached parentID chain (populated by session.created events).
|
|
16
|
+
* 2. On a cache miss, attempt a live client.session.get() lookup and cache
|
|
17
|
+
* whatever parentID the SDK returns.
|
|
18
|
+
* 3. Fall back to the orphan sentinel (sessionID itself) only when both
|
|
19
|
+
* cache and live lookup come up empty.
|
|
20
|
+
*/
|
|
21
|
+
async function resolveTeamId(sessionID) {
|
|
22
|
+
let current = sessionID;
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
while (current && !seen.has(current)) {
|
|
25
|
+
seen.add(current);
|
|
26
|
+
const team = getSessionTeam(current);
|
|
27
|
+
if (team !== undefined)
|
|
28
|
+
return team;
|
|
29
|
+
// Cache miss on this node's parent: try the live SDK.
|
|
30
|
+
if (client && !getParentSessionId(current)) {
|
|
31
|
+
try {
|
|
32
|
+
const result = await client.session.get({ path: { id: current } });
|
|
33
|
+
const parentID = result.data?.parentID;
|
|
34
|
+
if (parentID) {
|
|
35
|
+
registerParentSession(current, parentID);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Network / auth failure — let the walk continue to orphan.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
current = getParentSessionId(current);
|
|
43
|
+
}
|
|
44
|
+
return sessionID;
|
|
45
|
+
}
|
|
12
46
|
return {
|
|
13
47
|
config: async (config) => {
|
|
14
48
|
config.agent ??= {};
|
|
@@ -23,15 +57,9 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
23
57
|
},
|
|
24
58
|
'chat.message': async (input) => {
|
|
25
59
|
if (input.agent === AGENT_CTO) {
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
const activeTeamId = persistedTeamId ?? input.sessionID;
|
|
30
|
-
setActiveTeamSession(worktree, activeTeamId);
|
|
31
|
-
if (!persistedTeamId) {
|
|
32
|
-
// First CTO session for this worktree — persist this session as active team.
|
|
33
|
-
await setPersistedActiveTeam(worktree, activeTeamId);
|
|
34
|
-
}
|
|
60
|
+
// Each CTO session ID is its own team. The session ID is the durable
|
|
61
|
+
// identity: no worktree-global active-team state.
|
|
62
|
+
registerSessionTeam(input.sessionID, input.sessionID);
|
|
35
63
|
return;
|
|
36
64
|
}
|
|
37
65
|
if (input.agent && isEngineerAgent(input.agent)) {
|
|
@@ -39,7 +67,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
39
67
|
const existing = getWrapperSessionMapping(worktree, input.sessionID);
|
|
40
68
|
const persisted = existing ??
|
|
41
69
|
(await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
|
|
42
|
-
const teamId = persisted?.teamId ?? (await resolveTeamId(
|
|
70
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
|
|
43
71
|
setWrapperSessionMapping(worktree, input.sessionID, {
|
|
44
72
|
teamId,
|
|
45
73
|
workerName: engineer,
|
|
@@ -47,6 +75,14 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
47
75
|
await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
|
|
48
76
|
}
|
|
49
77
|
},
|
|
78
|
+
event: async ({ event: sdkEvent }) => {
|
|
79
|
+
if (sdkEvent.type === 'session.created') {
|
|
80
|
+
const session = sdkEvent.properties.info;
|
|
81
|
+
if (session.parentID) {
|
|
82
|
+
registerParentSession(session.id, session.parentID);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
50
86
|
'experimental.chat.system.transform': async (input, output) => {
|
|
51
87
|
if (!input.sessionID) {
|
|
52
88
|
return;
|
|
@@ -86,7 +122,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
86
122
|
const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
|
|
87
123
|
const persisted = existing ??
|
|
88
124
|
(await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
|
|
89
|
-
const teamId = persisted?.teamId ?? (await resolveTeamId(context.
|
|
125
|
+
const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
|
|
90
126
|
setWrapperSessionMapping(context.worktree, context.sessionID, {
|
|
91
127
|
teamId,
|
|
92
128
|
workerName: engineer,
|
|
@@ -117,7 +153,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
117
153
|
teamId: tool.schema.string().optional(),
|
|
118
154
|
},
|
|
119
155
|
async execute(args, context) {
|
|
120
|
-
const teamId = args.teamId ??
|
|
156
|
+
const teamId = args.teamId ?? context.sessionID;
|
|
121
157
|
annotateToolRun(context, 'Reading team status', {
|
|
122
158
|
teamId,
|
|
123
159
|
});
|
|
@@ -134,7 +170,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
134
170
|
model: tool.schema.enum(MODEL_ENUM).optional(),
|
|
135
171
|
},
|
|
136
172
|
async execute(args, context) {
|
|
137
|
-
const teamId =
|
|
173
|
+
const teamId = context.sessionID;
|
|
138
174
|
// Pre-determine engineers for event labeling (using orchestrator selection logic)
|
|
139
175
|
const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
|
|
140
176
|
annotateToolRun(context, 'Running dual-engineer plan synthesis', {
|
|
@@ -178,7 +214,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
|
|
|
178
214
|
clearHistory: tool.schema.boolean().optional(),
|
|
179
215
|
},
|
|
180
216
|
async execute(args, context) {
|
|
181
|
-
const teamId =
|
|
217
|
+
const teamId = context.sessionID;
|
|
182
218
|
annotateToolRun(context, `Resetting ${args.engineer}`, {
|
|
183
219
|
teamId,
|
|
184
220
|
clearSession: args.clearSession,
|
|
@@ -456,14 +492,6 @@ export function isEngineerAgent(agentId) {
|
|
|
456
492
|
const normalized = normalizeAgentId(agentId);
|
|
457
493
|
return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
|
|
458
494
|
}
|
|
459
|
-
/**
|
|
460
|
-
* Resolves the team ID for an engineer session.
|
|
461
|
-
* Reads the persisted active team first (survives process restarts), then
|
|
462
|
-
* falls back to the in-memory registry, then to the raw session ID as a last resort.
|
|
463
|
-
*/
|
|
464
|
-
async function resolveTeamId(worktree, sessionID) {
|
|
465
|
-
return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
|
|
466
|
-
}
|
|
467
495
|
function formatToolDescription(toolName, toolArgs) {
|
|
468
496
|
if (!toolArgs || typeof toolArgs !== 'object')
|
|
469
497
|
return undefined;
|
|
@@ -14,10 +14,10 @@ interface ClaudeManagerPluginServices {
|
|
|
14
14
|
}
|
|
15
15
|
export declare function getOrCreatePluginServices(worktree: string): ClaudeManagerPluginServices;
|
|
16
16
|
export declare function clearPluginServices(): void;
|
|
17
|
-
export declare function
|
|
18
|
-
export declare function
|
|
19
|
-
export declare function
|
|
20
|
-
export declare function
|
|
17
|
+
export declare function registerParentSession(childId: string, parentId: string): void;
|
|
18
|
+
export declare function getParentSessionId(childId: string): string | undefined;
|
|
19
|
+
export declare function registerSessionTeam(sessionId: string, teamId: string): void;
|
|
20
|
+
export declare function getSessionTeam(sessionId: string): string | undefined;
|
|
21
21
|
export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
|
|
22
22
|
teamId: string;
|
|
23
23
|
workerName: EngineerName;
|
|
@@ -10,8 +10,11 @@ import { TeamStateStore } from '../state/team-state-store.js';
|
|
|
10
10
|
import { TranscriptStore } from '../state/transcript-store.js';
|
|
11
11
|
import { buildWorkerCapabilities } from './agents/browser-qa.js';
|
|
12
12
|
const serviceRegistry = new Map();
|
|
13
|
-
const activeTeamRegistry = new Map();
|
|
14
13
|
const wrapperSessionRegistry = new Map();
|
|
14
|
+
/** childSessionId → parentSessionId — populated from session.created events */
|
|
15
|
+
const parentSessionRegistry = new Map();
|
|
16
|
+
/** ctoSessionId → teamId — populated when a CTO chat.message fires */
|
|
17
|
+
const sessionTeamRegistry = new Map();
|
|
15
18
|
export function getOrCreatePluginServices(worktree) {
|
|
16
19
|
const existing = serviceRegistry.get(worktree);
|
|
17
20
|
if (existing) {
|
|
@@ -40,28 +43,21 @@ export function getOrCreatePluginServices(worktree) {
|
|
|
40
43
|
}
|
|
41
44
|
export function clearPluginServices() {
|
|
42
45
|
serviceRegistry.clear();
|
|
43
|
-
activeTeamRegistry.clear();
|
|
44
46
|
wrapperSessionRegistry.clear();
|
|
47
|
+
parentSessionRegistry.clear();
|
|
48
|
+
sessionTeamRegistry.clear();
|
|
45
49
|
}
|
|
46
|
-
export function
|
|
47
|
-
|
|
50
|
+
export function registerParentSession(childId, parentId) {
|
|
51
|
+
parentSessionRegistry.set(childId, parentId);
|
|
48
52
|
}
|
|
49
|
-
export function
|
|
50
|
-
return
|
|
53
|
+
export function getParentSessionId(childId) {
|
|
54
|
+
return parentSessionRegistry.get(childId);
|
|
51
55
|
}
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
if (!services) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
return services.teamStore.getActiveTeam(worktree);
|
|
56
|
+
export function registerSessionTeam(sessionId, teamId) {
|
|
57
|
+
sessionTeamRegistry.set(sessionId, teamId);
|
|
58
58
|
}
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
if (!services) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
await services.teamStore.setActiveTeam(worktree, teamId);
|
|
59
|
+
export function getSessionTeam(sessionId) {
|
|
60
|
+
return sessionTeamRegistry.get(sessionId);
|
|
65
61
|
}
|
|
66
62
|
export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
|
|
67
63
|
wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
|
|
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
|
|
|
7
7
|
getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
|
|
8
8
|
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
9
9
|
updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
|
|
10
|
-
getActiveTeam(cwd: string): Promise<string | null>;
|
|
11
|
-
setActiveTeam(cwd: string, teamId: string): Promise<void>;
|
|
12
10
|
private getTeamKey;
|
|
13
|
-
private getActiveTeamPath;
|
|
14
11
|
private getTeamsDirectory;
|
|
15
12
|
private getTeamPath;
|
|
16
13
|
private enqueueWrite;
|
|
@@ -62,31 +62,9 @@ export class TeamStateStore {
|
|
|
62
62
|
return updated;
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
async getActiveTeam(cwd) {
|
|
66
|
-
const filePath = this.getActiveTeamPath(cwd);
|
|
67
|
-
try {
|
|
68
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
69
|
-
const parsed = JSON.parse(content);
|
|
70
|
-
return parsed.teamId ?? null;
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
if (isFileNotFoundError(error)) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
async setActiveTeam(cwd, teamId) {
|
|
80
|
-
const filePath = this.getActiveTeamPath(cwd);
|
|
81
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
82
|
-
await writeJsonAtomically(filePath, { teamId });
|
|
83
|
-
}
|
|
84
65
|
getTeamKey(cwd, teamId) {
|
|
85
66
|
return `${cwd}:${teamId}`;
|
|
86
67
|
}
|
|
87
|
-
getActiveTeamPath(cwd) {
|
|
88
|
-
return path.join(cwd, this.baseDirectoryName, 'active-team.json');
|
|
89
|
-
}
|
|
90
68
|
getTeamsDirectory(cwd) {
|
|
91
69
|
return path.join(cwd, this.baseDirectoryName, 'teams');
|
|
92
70
|
}
|
|
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
|
|
|
7
7
|
getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
|
|
8
8
|
listTeams(cwd: string): Promise<TeamRecord[]>;
|
|
9
9
|
updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
|
|
10
|
-
getActiveTeam(cwd: string): Promise<string | null>;
|
|
11
|
-
setActiveTeam(cwd: string, teamId: string): Promise<void>;
|
|
12
10
|
private getTeamKey;
|
|
13
|
-
private getActiveTeamPath;
|
|
14
11
|
private getTeamsDirectory;
|
|
15
12
|
private getTeamPath;
|
|
16
13
|
private enqueueWrite;
|
|
@@ -62,31 +62,9 @@ export class TeamStateStore {
|
|
|
62
62
|
return updated;
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
async getActiveTeam(cwd) {
|
|
66
|
-
const filePath = this.getActiveTeamPath(cwd);
|
|
67
|
-
try {
|
|
68
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
69
|
-
const parsed = JSON.parse(content);
|
|
70
|
-
return parsed.teamId ?? null;
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
if (isFileNotFoundError(error)) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
async setActiveTeam(cwd, teamId) {
|
|
80
|
-
const filePath = this.getActiveTeamPath(cwd);
|
|
81
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
82
|
-
await writeJsonAtomically(filePath, { teamId });
|
|
83
|
-
}
|
|
84
65
|
getTeamKey(cwd, teamId) {
|
|
85
66
|
return `${cwd}:${teamId}`;
|
|
86
67
|
}
|
|
87
|
-
getActiveTeamPath(cwd) {
|
|
88
|
-
return path.join(cwd, this.baseDirectoryName, 'active-team.json');
|
|
89
|
-
}
|
|
90
68
|
getTeamsDirectory(cwd) {
|
|
91
69
|
return path.join(cwd, this.baseDirectoryName, 'teams');
|
|
92
70
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the session-per-team CTO model:
|
|
3
|
+
* - Each CTO session ID is its own team ID (no shared repo-global state).
|
|
4
|
+
* - Same CTO session ID recovers the same team context across restarts.
|
|
5
|
+
* - A different CTO session ID in the same worktree creates an independent team.
|
|
6
|
+
* - Engineers spawned within a CTO session resolve to that CTO's team.
|
|
7
|
+
* - CTO task permissions do not allow self-delegation.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
10
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
11
|
import { join } from 'node:path';
|
|
4
12
|
import { tmpdir } from 'node:os';
|
|
5
13
|
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
6
|
-
import { clearPluginServices,
|
|
7
|
-
import { AGENT_CTO } from '../src/plugin/
|
|
8
|
-
|
|
9
|
-
describe('CTO chat.message — persisted active team', () => {
|
|
14
|
+
import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, getWrapperSessionMapping, registerParentSession, } from '../src/plugin/service-factory.js';
|
|
15
|
+
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agents/index.js';
|
|
16
|
+
describe('CTO chat.message — session-per-team model', () => {
|
|
10
17
|
let tempRoot;
|
|
11
18
|
beforeEach(async () => {
|
|
12
19
|
tempRoot = await mkdtemp(join(tmpdir(), 'cto-team-'));
|
|
@@ -18,35 +25,175 @@ describe('CTO chat.message — persisted active team', () => {
|
|
|
18
25
|
await rm(tempRoot, { recursive: true, force: true });
|
|
19
26
|
}
|
|
20
27
|
});
|
|
21
|
-
it('
|
|
28
|
+
it('CTO session ID is used directly as the team ID', async () => {
|
|
22
29
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
23
30
|
const chatMessage = plugin['chat.message'];
|
|
24
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: 'session-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
32
|
+
expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
|
|
33
|
+
});
|
|
34
|
+
it('same CTO session ID recovers its team context after a process restart', async () => {
|
|
35
|
+
// Phase 1: CTO session 'cto-1' runs and a team record is created on disk.
|
|
36
|
+
const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
37
|
+
const chatMessage1 = plugin1['chat.message'];
|
|
38
|
+
await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
39
|
+
const services1 = getOrCreatePluginServices(tempRoot);
|
|
40
|
+
await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1'); // persist team to disk
|
|
41
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
42
|
+
// Phase 2: Simulate a process restart (all in-memory state is lost).
|
|
43
|
+
clearPluginServices();
|
|
44
|
+
// Phase 3: The same CTO session ID resumes — it should re-register itself.
|
|
45
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
46
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
47
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
48
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
49
|
+
// The persisted team record from Phase 1 should still be accessible.
|
|
50
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
51
|
+
const team = await services2.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
52
|
+
expect(team.id).toBe('cto-1');
|
|
53
|
+
});
|
|
54
|
+
it('a different CTO session ID creates an independent team, not adopting prior state', async () => {
|
|
55
|
+
// Phase 1: CTO session 'cto-1' runs.
|
|
56
|
+
const plugin1 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
57
|
+
const chatMessage1 = plugin1['chat.message'];
|
|
58
|
+
await chatMessage1({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
59
|
+
const services1 = getOrCreatePluginServices(tempRoot);
|
|
60
|
+
await services1.orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
61
|
+
// Phase 2: Process restart.
|
|
62
|
+
clearPluginServices();
|
|
63
|
+
// Phase 3: A brand-new CTO session 'cto-2' starts.
|
|
64
|
+
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
65
|
+
const chatMessage2 = plugin2['chat.message'];
|
|
66
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-2' });
|
|
67
|
+
// Must use its OWN session ID as team — must NOT adopt 'cto-1'.
|
|
68
|
+
expect(getSessionTeam('cto-2')).toBe('cto-2');
|
|
69
|
+
expect(getSessionTeam('cto-1')).toBeUndefined(); // cleared by restart
|
|
70
|
+
// 'cto-1' team data remains on disk, untouched.
|
|
71
|
+
const services2 = getOrCreatePluginServices(tempRoot);
|
|
72
|
+
const team1 = await services2.teamStore.getTeam(tempRoot, 'cto-1');
|
|
73
|
+
expect(team1).not.toBeNull(); // still present
|
|
74
|
+
expect(team1.id).toBe('cto-1');
|
|
75
|
+
});
|
|
76
|
+
it('multiple chat.message calls from the same CTO session do not change the active team', async () => {
|
|
33
77
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
34
78
|
const chatMessage = plugin['chat.message'];
|
|
35
|
-
|
|
36
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: '
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
80
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
81
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-session-1' });
|
|
82
|
+
expect(getSessionTeam('cto-session-1')).toBe('cto-session-1');
|
|
83
|
+
});
|
|
84
|
+
it("engineers spawned during a CTO session resolve to that CTO session's team", async () => {
|
|
85
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
86
|
+
const chatMessage = plugin['chat.message'];
|
|
87
|
+
// CTO session fires first, establishing the active team.
|
|
88
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
|
|
89
|
+
// Simulate session.created event: OpenCode fires this before chat.message for the engineer.
|
|
90
|
+
registerParentSession('wrapper-tom-1', 'cto-A');
|
|
91
|
+
// Engineer wrapper session fires (spawned by the CTO).
|
|
92
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-1' });
|
|
93
|
+
// The wrapper session must be mapped to the CTO's team, not a new orphan team.
|
|
94
|
+
const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-1');
|
|
95
|
+
expect(mapping).toBeDefined();
|
|
96
|
+
expect(mapping.teamId).toBe('cto-A');
|
|
97
|
+
expect(mapping.workerName).toBe('Tom');
|
|
98
|
+
});
|
|
99
|
+
it('two concurrent CTO sessions each bind their own engineers independently', async () => {
|
|
100
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
101
|
+
const chatMessage = plugin['chat.message'];
|
|
102
|
+
// Two CTO sessions start concurrently in the same worktree.
|
|
103
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-alpha' });
|
|
104
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-beta' });
|
|
105
|
+
// Simulate session.created events: each CTO spawns one engineer sub-session.
|
|
106
|
+
// The event hook normally calls registerParentSession; call it directly here.
|
|
107
|
+
registerParentSession('wrapper-tom-alpha', 'cto-alpha');
|
|
108
|
+
registerParentSession('wrapper-sara-beta', 'cto-beta');
|
|
109
|
+
// Engineer wrapper sessions check in.
|
|
110
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-alpha' });
|
|
111
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Sara, sessionID: 'wrapper-sara-beta' });
|
|
112
|
+
// Tom must bind to cto-alpha's team, not cto-beta's.
|
|
113
|
+
const tomMapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-alpha');
|
|
114
|
+
expect(tomMapping).toBeDefined();
|
|
115
|
+
expect(tomMapping.teamId).toBe('cto-alpha');
|
|
116
|
+
// Sara must bind to cto-beta's team, not cto-alpha's.
|
|
117
|
+
const saraMapping = getWrapperSessionMapping(tempRoot, 'wrapper-sara-beta');
|
|
118
|
+
expect(saraMapping).toBeDefined();
|
|
119
|
+
expect(saraMapping.teamId).toBe('cto-beta');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('CTO task permissions — self-delegation', () => {
|
|
123
|
+
let tempRoot;
|
|
124
|
+
beforeEach(async () => {
|
|
125
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'cto-perms-'));
|
|
126
|
+
clearPluginServices();
|
|
127
|
+
});
|
|
128
|
+
afterEach(async () => {
|
|
129
|
+
clearPluginServices();
|
|
130
|
+
if (tempRoot)
|
|
131
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
it('CTO task permissions deny delegation to cto', async () => {
|
|
44
134
|
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
135
|
+
const config = {};
|
|
136
|
+
await plugin.config?.(config);
|
|
137
|
+
const agents = (config.agent ?? {});
|
|
138
|
+
const cto = agents[AGENT_CTO];
|
|
139
|
+
const taskPerms = cto.permission.task;
|
|
140
|
+
// Default deny must apply, and cto must not be explicitly allowed.
|
|
141
|
+
expect(taskPerms['*']).toBe('deny');
|
|
142
|
+
expect(taskPerms['cto']).toBeUndefined();
|
|
143
|
+
expect(taskPerms['CTO']).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('CTO tool isolation — concurrent sessions', () => {
|
|
147
|
+
let tempRoot;
|
|
148
|
+
beforeEach(async () => {
|
|
149
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'cto-isolation-'));
|
|
150
|
+
clearPluginServices();
|
|
151
|
+
});
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
clearPluginServices();
|
|
154
|
+
if (tempRoot)
|
|
155
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
it('team_status called from CTO-A uses CTO-A team even after CTO-B has chatted', async () => {
|
|
158
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
159
|
+
const chatMessage = plugin['chat.message'];
|
|
160
|
+
// CTO-A registers first, then CTO-B registers (simulating two concurrent sessions).
|
|
161
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-A' });
|
|
162
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-B' });
|
|
163
|
+
// Execute team_status as CTO-A (sessionID = 'cto-A').
|
|
164
|
+
const teamStatusTool = plugin.tool['team_status'];
|
|
165
|
+
const ctx = {
|
|
166
|
+
metadata: vi.fn(),
|
|
167
|
+
worktree: tempRoot,
|
|
168
|
+
sessionID: 'cto-A',
|
|
169
|
+
agent: AGENT_CTO,
|
|
170
|
+
abort: new AbortController().signal,
|
|
171
|
+
};
|
|
172
|
+
const result = JSON.parse(await teamStatusTool.execute({}, ctx));
|
|
173
|
+
// Must load cto-A's team, NOT cto-B's (last-write-wins global would give 'cto-B').
|
|
174
|
+
expect(result.id).toBe('cto-A');
|
|
175
|
+
});
|
|
176
|
+
it('resolves engineer team via live SDK lookup when session.created was not received', async () => {
|
|
177
|
+
// Simulate a client whose session.get returns parentID for the engineer session.
|
|
178
|
+
const mockClient = {
|
|
179
|
+
session: {
|
|
180
|
+
get: vi.fn().mockImplementation(async ({ path }) => {
|
|
181
|
+
if (path.id === 'wrapper-tom-live') {
|
|
182
|
+
return { data: { id: 'wrapper-tom-live', parentID: 'cto-live' } };
|
|
183
|
+
}
|
|
184
|
+
return { data: undefined };
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const plugin = await ClaudeManagerPlugin({ worktree: tempRoot, client: mockClient });
|
|
45
189
|
const chatMessage = plugin['chat.message'];
|
|
46
|
-
|
|
47
|
-
await chatMessage({ agent: AGENT_CTO, sessionID: '
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
190
|
+
// CTO registers its team via chat.message.
|
|
191
|
+
await chatMessage({ agent: AGENT_CTO, sessionID: 'cto-live' });
|
|
192
|
+
// No registerParentSession call — simulates session.created arriving late or being missed.
|
|
193
|
+
// Engineer wrapper fires; resolveTeamId must fall through to live SDK lookup.
|
|
194
|
+
await chatMessage({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-live' });
|
|
195
|
+
const mapping = getWrapperSessionMapping(tempRoot, 'wrapper-tom-live');
|
|
196
|
+
expect(mapping?.teamId).toBe('cto-live');
|
|
197
|
+
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: 'wrapper-tom-live' } });
|
|
51
198
|
});
|
|
52
199
|
});
|
|
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
|
|
11
|
-
import { clearPluginServices,
|
|
11
|
+
import { clearPluginServices, getOrCreatePluginServices, getSessionTeam, registerParentSession, } from '../src/plugin/service-factory.js';
|
|
12
12
|
import { AGENT_CTO, ENGINEER_AGENT_IDS } from '../src/plugin/agent-hierarchy.js';
|
|
13
13
|
import { TeamStateStore } from '../src/state/team-state-store.js';
|
|
14
14
|
import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
|
|
@@ -197,23 +197,25 @@ describe('second invocation continuity', () => {
|
|
|
197
197
|
if (tempRoot)
|
|
198
198
|
await rm(tempRoot, { recursive: true, force: true });
|
|
199
199
|
});
|
|
200
|
-
it('wrapper memory is injected after clearPluginServices and
|
|
200
|
+
it('wrapper memory is injected after clearPluginServices and same CTO session resumes', async () => {
|
|
201
201
|
// ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
|
|
202
|
+
// Team ID = 'cto-1' (the CTO session ID that originally ran this work).
|
|
202
203
|
const store = new TeamStateStore();
|
|
203
|
-
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
204
204
|
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
205
205
|
await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
|
|
206
206
|
await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
|
|
207
207
|
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
208
208
|
clearPluginServices();
|
|
209
|
-
// ── Phase 3:
|
|
209
|
+
// ── Phase 3: same CTO session resumes, engineers run a new wrapper ──────
|
|
210
210
|
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
211
211
|
const chatMessage2 = plugin2['chat.message'];
|
|
212
212
|
const systemTransform2 = plugin2['experimental.chat.system.transform'];
|
|
213
|
-
//
|
|
214
|
-
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-
|
|
215
|
-
expect(
|
|
216
|
-
//
|
|
213
|
+
// Same CTO session ID resumes — re-registers itself as the team.
|
|
214
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
215
|
+
expect(getSessionTeam('cto-1')).toBe('cto-1');
|
|
216
|
+
// Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
|
|
217
|
+
registerParentSession('wrapper-tom-2', 'cto-1');
|
|
218
|
+
// Tom's new wrapper session is registered under the same team.
|
|
217
219
|
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
218
220
|
// Transform fires (after chat.message has registered the session mapping).
|
|
219
221
|
const output = { system: [] };
|
|
@@ -224,9 +226,8 @@ describe('second invocation continuity', () => {
|
|
|
224
226
|
expect(output.system[0]).toContain('Found two race conditions');
|
|
225
227
|
});
|
|
226
228
|
it('existing engineer Claude session is resumed on second invocation', async () => {
|
|
227
|
-
// ── Phase 1: pre-seed Tom with a claudeSessionId
|
|
229
|
+
// ── Phase 1: pre-seed Tom with a claudeSessionId under team 'cto-1' ────
|
|
228
230
|
const store = new TeamStateStore();
|
|
229
|
-
await store.setActiveTeam(tempRoot, 'cto-1');
|
|
230
231
|
const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Synthesis prompt', { BrowserQA: BROWSER_QA_TEST_CAPS });
|
|
231
232
|
await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
|
|
232
233
|
await store.updateTeam(tempRoot, 'cto-1', (team) => ({
|
|
@@ -235,10 +236,13 @@ describe('second invocation continuity', () => {
|
|
|
235
236
|
}));
|
|
236
237
|
// ── Phase 2: process restart ───────────────────────────────────────────
|
|
237
238
|
clearPluginServices();
|
|
238
|
-
// ── Phase 3:
|
|
239
|
+
// ── Phase 3: same CTO session resumes, engineer runs second task ───────
|
|
239
240
|
const plugin2 = await ClaudeManagerPlugin({ worktree: tempRoot });
|
|
240
241
|
const chatMessage2 = plugin2['chat.message'];
|
|
241
|
-
|
|
242
|
+
// Same CTO session ID — re-registers as the team so Tom can find his session.
|
|
243
|
+
await chatMessage2({ agent: AGENT_CTO, sessionID: 'cto-1' });
|
|
244
|
+
// Simulate session.created: OpenCode fires this when cto-1 spawns the new wrapper.
|
|
245
|
+
registerParentSession('wrapper-tom-2', 'cto-1');
|
|
242
246
|
await chatMessage2({ agent: ENGINEER_AGENT_IDS.Tom, sessionID: 'wrapper-tom-2' });
|
|
243
247
|
const services2 = getOrCreatePluginServices(tempRoot);
|
|
244
248
|
// Mock at the session level so dispatchEngineer runs its real logic
|
|
@@ -51,22 +51,4 @@ describe('TeamStateStore', () => {
|
|
|
51
51
|
{ id: 'older' },
|
|
52
52
|
]);
|
|
53
53
|
});
|
|
54
|
-
it('returns null for active team when no active-team.json exists', async () => {
|
|
55
|
-
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
56
|
-
const store = new TeamStateStore('.state');
|
|
57
|
-
await expect(store.getActiveTeam(tempRoot)).resolves.toBeNull();
|
|
58
|
-
});
|
|
59
|
-
it('persists and reads back the active team ID', async () => {
|
|
60
|
-
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
61
|
-
const store = new TeamStateStore('.state');
|
|
62
|
-
await store.setActiveTeam(tempRoot, 'team-abc');
|
|
63
|
-
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-abc');
|
|
64
|
-
});
|
|
65
|
-
it('overwrites the active team ID on subsequent writes', async () => {
|
|
66
|
-
tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
|
|
67
|
-
const store = new TeamStateStore('.state');
|
|
68
|
-
await store.setActiveTeam(tempRoot, 'team-first');
|
|
69
|
-
await store.setActiveTeam(tempRoot, 'team-second');
|
|
70
|
-
await expect(store.getActiveTeam(tempRoot)).resolves.toBe('team-second');
|
|
71
|
-
});
|
|
72
54
|
});
|