Conclusion first: the current codebase can only run one
AgentSystemsafely within a single JVM process. The root cause is notAgentSystemitself, but the set of process-level global singletons / static mutable state / global file paths it depends on. If a secondAgentSystemis created, the two systems share these global resources, causing roles and computers to overwrite each other, clock-event crosstalk, mail/note/journal and other data files to overwrite each other, and other unpredictable anomalies. This document evaluates the problems and presents the refactor plan to be implemented per this report.
| Location | Global object | Shared content | Consequences when two systems coexist |
|---|---|---|---|
computers/ComputerManager.java:44 |
INSTANCE (role computer registry) |
role_id → Computer mapping, role_id → person name mapping |
① registering the same role_id in the second system overwrites the first system's computer object; ② one system dismissing/destroying a computer deletes the other system's container; ③ lan_devices lists roles from both systems mixed together; ④ archive restore binds to the wrong computer |
tools/Toolkits.java:42,45 |
MCP_MANAGER / SKILL_MANAGER |
MCP tool installation records (role_id → Set<tool names>), skill library installation records |
two systems with the same role_id share the "installed tools" bookkeeping; mcp_my_tools / skill_my_skills pollute each other; the skill library's default directory data/skills is also shared globally |
services/MailService.java:429 |
mailService lazy-loaded singleton |
all mailboxes (mailboxes in-memory table + data/mail/mailboxes.json) |
mail is fully shared between the two systems (system A sends, system B's inbox sees it too), and archives overwrite each other |
tools/toolkits/client/ClientCommunicationLock.java:15 |
INSTANCE (mutex lock for Client A conversations) |
lock holder | once a system A member holds the lock, system B members cannot talk to Client A (cross-system mutual blocking) |
event/TimeEventBus.java:580 |
getDefaultBus() process-level default clock |
shared tick/day state and event dispatch table | AgentRole.timeManager() silently falls back to the process-level default clock when not explicitly bound; unbound roles from both systems share the same clock, ticks/days jump for each other, and scheduled events crosstalk |
| Location | Static state | Consequences |
|---|---|---|
role/RoleLoader.java:84 |
TEMPLATES global template registry (mutable) |
RoleFactory hiring (HR tool) registers new role templates into the global table; roles hired by system A appear in system B's template pool |
role/RoleLoader.java:305-306 |
usedNames / namePoolInitialized |
global name allocator, shared across systems (minor impact, but still process-level shared state) |
role/AgentRole.java:50 |
JOURNAL_DIR (static, can be rewritten by tests) |
two systems with the same role_id append activity journals to the same file |
| Location | Path | Problem |
|---|---|---|
role/AgentRole.java:50 |
data/journals/ |
two systems with the same role_id write the same journal file |
store/NoteStore.java:41 |
PathManager user directory ~/.local/share/AgentSoftware/notes |
① inconsistent with the rest of the project's ./data/* layout (.gitignore explicitly lists data/notes/); ② two systems with the same role_id share the notes directory |
store/TodoStore.java:35 |
./data/todos/<role_id>.json |
two systems with the same role_id share the same to-dos |
services/MailService.java:39 |
data/mail/mailboxes.json |
one mail archive shared globally |
store/StateStore.java:41 |
./data/state.json |
the two systems' save/restore overwrite each other (the later save overwrites the earlier one) |
computers/Computer.java:34-35 |
./data/computers, ./data/drive |
local simulated computer directories/cloud drive shared globally |
tools/toolkits/skill/SkillManager.java:90 |
data/skills |
skill library shared globally |
AgentRole and the various tool classes call the global singletons directly without dependency injection:
AgentRole.computer()→ComputerManager.getInstance().create(...)AgentRole.mailAddress()→MailService.getMailService().emailFor(this)AgentRole.timeManager()→ falls back toTimeEventBus.getDefaultBus()when unboundRolePool.setupRole()→Toolkits.getMcpManager().installGroupDefaults(...)RolePool.removeRole()→ComputerManager.getInstance().destroy(roleId)toolkits/pc/LanDevices→ComputerManager.getInstance().listLanDevices()toolkits/email/Email→ falls back toMailService.getMailService()by defaulttoolkits/client/TalkToClient→ falls back toClientCommunicationLock.getInstance()by defaultstore/StateStore→ComputerManager.getInstance().create/nameOf
NoteStore's default directory goes throughPathManager(user home directory~/.local/share/AgentSoftware/notes), which contradicts the project's./data/*layout and thedata/notes/convention in.gitignore; in a read-only home directory environment, tests fail outright (6 test cases failed for this reason in this repo's sandbox).- The environment variable prefix
AGENTSCHEDULER_DATA_DIRused by tests and the README is inconsistent with the prefix thatPathManageractually derives (appName="AgentSoftware"→AGENTSOFTWARE_DATA_DIR), so the data-directory property set in tests never takes effect.
- Role registration conflict:
RolePool.addRolethrows for a duplicaterole_idwithIllegalArgumentException("Role 'xxx' already exists"); even though each system holds its own independentRolePool, the computer registry still overwrites each other becauseComputerManageris a global singleton. - Clock crosstalk: a system A role goes on duty at Tick 30; system B's unbound roles fall back to
TimeEventBus.getDefaultBus(), and the two systems' shift events trigger each other on the same clock line. - Data overwrite: system A's and system B's
StateStoreboth write./data/state.json; the later save overwrites the earlier one; the notes/to-dos/mail/journals of the samerole_idin both systems write the same set of files. - Mutex lock blocking across systems: while system A's CEO is talking to Client A, all members of system B
have
talk_to_clientrejected. - Container-deletion accident: when system A dismisses an employee, it deletes the container of the same-named
role in system B with
podman rm -fvia the globalComputerManager.
Core idea: change "process-level global singletons" into "one instance per AgentSystem", held directly by AgentSystem.
No additional packaging classes are introduced: AgentSystem creates and directly holds its own set of
collaboration objects and data root directory at construction time, explicitly injected via RolePool → AgentRole → tool classes; one system
can run independently, and multiple systems do not interfere with each other.
New direct fields on AgentSystem (all per-system independent instances; multiple instances do not interfere):
timeManager(TimeEventBus) — one clock per system (existing)configStore(ConfigStore)computerManager(ComputerManager) — one role computer registry per systemmailService(MailService) — one mailbox set per system (data underdataDir/mail)mcpManager(MCPManager) /skillManager(SkillManager) — one per systemclientLock(ClientCommunicationLock) — one Client A conversation lock per systemchatStore(ChatStore) — one chat store per system (data source for the Web UI)conversationManager(ConversationManager) — one role ↔ LLM API dialogue registry per system (each role's day conversation lives here, so multiple systems never share dialogue state)dataDir(Path) — per-system data root directory, default./data
Derived data directories (all rooted at dataDir): journalDir/notesDir/todosDir/mailDir/ computersDir/driveDir/skillsDir/stateFile.
Multi-instance usage: pass a different dataDir to each AgentSystem for complete file-level isolation:
new AgentSystem(Paths.get("data/company-a"), null, roleIds, 30.0, true).
| File | Change |
|---|---|
AgentSystem |
Add direct fields computerManager/mailService/mcpManager/skillManager/clientLock/chatStore/conversationManager/dataDir created at construction time; add an AgentSystem(Path dataDir, ...) overload (original signature stays compatible); addRoles unconditionally binds bindTimeManager + bindSystem(this); add accessors for each data directory |
RolePool |
Add a constructor overload carrying AgentSystem owner (may be null = standalone role pool); setupRole binds bindSystem; default MCP groups use role.mcpManager(); removeRole uses role.computerManager(); newLlm uses owner.configStore |
AgentRole |
Add a system field and bindSystem()/system(); add computerManager()/mailService()/clientLock()/mcpManager()/skillManager()/chatStore()/conversation() resolution helpers (use the system instance when owned by a system, otherwise fall back to global defaults to keep legacy usage compatible); computer()/mailAddress()/noteStore()/todoStore()/journal() all go through the owning system |
Toolkits.defaultToolkits |
Build tool classes with role.mcpManager()/skillManager()/mailService() (roles inside a system get that system's instances) |
toolkits/client/Client + TalkToClient |
Use role.system().clientLock / .chatStore (unbound systems fall back to global defaults / no chat store) |
toolkits/talk/TalkTo |
recordTalk uses role.system().chatStore |
web/ChatWebServer + WebDemo |
Use system.chatStore instead |
store/NoteStore |
Change the default base directory to data/notes (consistent with .gitignore and the project-wide layout; explicit parameter behavior unchanged) |
store/StateStore |
collect/restoreComputers use system.computerManager |
computers/ComputerManager |
Make the constructor public (allow new per system); keep getInstance() as the default fallback |
RoleLoader.TEMPLATES/ name pool: role templates are the "company organizational structure definition" on the classpath, a process-level shared registry; new templates registered by HR hiring are visible within the process (consistent with single-system semantics). For isolation, a per-system template table could be added toAgentSystem(not in this iteration).- podman network / base image / container names: the
maf-netnetwork,maf-base:latestimage, andmaf-<role_id>container names are host-level infrastructure shared across systems (created idempotently). Multi-instance deployment constraint: if two AgentSystems on the same host use podman computers with the samerole_id, the container names will conflict; multi-instance scenarios should uselocalcomputers (with per-systembase_dir/drive_dir) or non-overlapping role sets. ConfigStoredefault config path: process-level configuration such as LLM (~/.config/AgentSoftware/config.json) stays shared (environment variables take precedence; the config is read-only in nature); all data-class state is isolated per system.
- All existing public constructors and fields of
AgentSystem/RolePool/AgentRoleare kept;Main.java, demo programs, and existing tests need no structural changes (only Web-related tests change to accesssystem.chatStoredirectly). - Single-system usage behaves the same (data stays under
./data/*); onlyNoteStore's default directory moves from the user home todata/notes. - Standalone roles/pools not bound to a system (demos, unit tests) continue to fall back to global defaults, as before the refactor.
mvn testall green (including the multi-instance isolation testAgentSystemIsolationTestand the Web tests).- New tests cover: two systems' roles/clocks/computer registries/mailboxes/mutex locks/chat stores/data directories do not
interfere; the same
role_idis independent across the two systems; standalone roles not bound to a system fall back to global defaults.
- If full "multiple companies in one process"-level isolation is needed,
RoleLoader.TEMPLATESandConfigStorecould also becomeAgentSystem-owned. - podman container names support a per-system prefix (e.g.
maf-<system>-<role_id>), eliminating container-name conflicts for multiple instances on the same host. - Add
close()/resource-reclamation semantics toAgentSystemso the lifecycles of multiple systems can be managed independently.