bus: projects and threads, schema 3d to 3e (row 121, #1615)

Darkwing's row 121 round 2 (agents/darkwing/work/queue-121/r2, base
20d2b5788, manifest 710d5086...e67e9b, 30 files), applied unchanged on
d7ebaa9a5; no commit since the base touches any of the 30 paths. Dewey
asked for changes on round 1 in comment 28591 (F1 to F6) and approved
round 2 in 28615. Landing gate on d7ebaa9a5 plus the candidate: all 24
suites passed, 0 failed.

F6 and Dewey's three round 2 notes are follow-ups (sage-0369, P19 task
311). One of them corrects the packet: BUILD.md says the decision-route
setup's projects key is needed, and Dewey's probe showed project.create
reads it only with repo, so the key is dead setup.

The live bus stays at 3b until Mos's schema window migrates it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 14:06:05 -05:00
co-authored by Claude Opus 5.5
parent d7ebaa9a51
commit 2c16d6b932
30 changed files with 2317 additions and 79 deletions
+83 -7
View File
@@ -160,6 +160,76 @@ an operator inspect content and delivery evidence after an uncertain result.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,after?,limit?}`; human sessions only. The thread's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the thread), at most `limit` (default 50, 1 to 200). Writes nothing. |
### Projects and threads (row 121, #1615)
A thread is one conversation between a user and the business's thread instance, the role
whose definition is `assistant` (REQ-THREAD-1). Projects group threads (REQ-ORG-2). Schema
`3e` adds five append-only tables: `projects`, `threads`, `thread_claims`, `message_threads`
and `thread_opens`. A message's thread is a `message_threads` row written with the message,
so `messages` keeps its old columns.
A thread instance's run is for one thread. `bindLaunch` refuses a record for that role
without `thread` (`thread-required`) or naming a thread of another instance or business
(`thread-not-found`), and a record for any other role that names one (`thread-not-allowed`).
The run's `role.claim` writes a `thread_claims` row, not a `role_claims` row. One run holds
a thread at a time (`thread-already-held`), and a run holds at most one thread. An idle
thread has no run and holds nothing, so it counts against no launch limit (REQ-THREAD-2).
Every message to or from a thread instance names its thread (REQ-MSG-1):
- A thread run's own message is in its thread.
- A human reaches a thread instance only through `thread.send`. A `message.send` to it
refuses `thread-required-use-thread-send`.
- Any other role's message to a thread instance needs `in_reply_to`. The broker walks the
`in_reply_to` chain to the nearest message that has a thread, so the chief of staff's
reply returns to the thread that sent the work. The walk stops where the chain ends; there
is no depth constant. No threaded ancestor refuses `thread-required-reply-to-thread-message`,
which is the whole text a run sees, so it says what to do. A threaded ancestor in another
instance's thread refuses `thread-instance-mismatch`.
- A thread run's `message.receive` returns only its own thread's messages.
Triggers back the broker. `thread_one_holder` and `thread_one_per_run` refuse a second
holder and a second thread. `message_threads_match` refuses filing a message under a thread
of another business, or under a thread whose instance neither sent nor receives it.
`delivery_same_thread` refuses routing or delivering a threaded message to the instance to
any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
messages to the instance not yet delivered, messages delivered to the current holder and
not yet read, the latest message, open and claim times, and the holding run or `null`. The
launcher reads it to start and stop thread sessions (`packages/cli/README.md`).
`threadIdle({business,thread,run})` is the launcher's idle stop, also trusted and not a
verb. It rechecks the thread and releases the run's claim in one call, and returns
`{released:true}`, or `{released:false,pending,unread}` when a message came in after the
launcher's snapshot. The launcher signals the run only after a release, so a message sent
after the snapshot gets a turn from the same run or waits as `pending` for the next one. It
refuses `not-holder` for a run that doesn't hold that thread and `run-ended` for an ended run.
A message delivered to a thread run that then ended without reading it is stranded, as
`stranded` (Agent state, below) describes for a role. It counts as neither `pending` nor
`unread`, so it neither starts a session nor keeps a later one from idling out. The user
sends it again.
`role.revoke` doesn't reach a thread run. A thread instance has no `role_claims` row, so a
decision to revoke it names no holder run, and using that decision refuses `decision-mismatch`.
The chief of staff's reason path refuses `role-end-refused`, since the assistant is protected
(row 123). A stuck thread run is stopped instead: the launcher stops it
once the thread is idle, the human stops it with `mosaic stop <run>`, and the run's end
releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
### Agent state (row 59, #1548)
@@ -455,10 +525,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3c, SHA-256
`7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb`.
It has nine tables, 40 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3c`, WAL,
`schema.sql` is v3e, SHA-256
`21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050`.
It has 14 tables, 64 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3e`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
@@ -471,7 +541,7 @@ poll writes no cursor. Growth is at most one row per observed new comment, and
usually one per batch. IDs include bots, but bot comments never contribute to
the external-comment count. Task IDs survive project moves within a business.
Normal startup refuses a 3b database. The explicit offline command is:
Normal startup refuses a database at any earlier version. The explicit offline command is:
```sh
node packages/bus/src/migrate-schema.mjs /absolute/dataRoot
@@ -486,8 +556,14 @@ and digest metadata, restores the metadata update guard, and verifies its target
digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. The 3c→3d
step (row 123) replaces the `role_revoke_needs_decision` trigger: a revoke needs a resolved
decision or a reason. Storage accepts a revoke with a reason and no decision, so only the
broker decides who may revoke that way (`role.end`, in the `role.revoke` row above). Prior
steps stay pinned under `src/migrations/`; later versions append a step. A completed
broker decides who may revoke that way (`role.end`, in the `role.revoke` row above). The
3d→3e step (row 121) adds the project, thread and thread-claim tables and changes no row.
Messages from before 3e have no thread. A thread run receives only its own thread's
messages, so an undelivered one to a thread instance is never delivered. The step leaves
those messages as written and does not attach them to a thread. The command prints one
line per business and recipient role with the count of undelivered messages it kept,
before the ready line. The human re-sends any that were meant for a thread instance with
`thread.send`. Prior steps stay pinned under `src/migrations/`; later versions append a step. A completed
step remains valid if a later step refuses; rerunning resumes from that version.
Already-current migration is a validated no-op. Missing stores refuse.
+92
View File
@@ -227,3 +227,95 @@ CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursor
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
+315 -13
View File
@@ -103,6 +103,14 @@ const VERDICTS = ['approve', 'changes'];
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
// action.allowed for git.push.working that names its commit, written only by recordPush.
const REVIEW_KINDS = ['review.requested', 'review.verdict'];
// Row 121 (#1615): an instance of this definition talks with the user in threads (REQ-THREAD-1).
// Its runs claim a thread, not the role, and every message to or from it names its thread (REQ-MSG-1).
const THREAD_DEFINITION = 'assistant';
// How many messages `thread.messages` returns by default and at most, oldest first.
export const THREAD_PAGE = 50;
export const THREAD_PAGE_MAX = 200;
// A project or thread name: what the user calls it, one line.
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
export class Broker {
#store;
@@ -133,6 +141,10 @@ export class Broker {
'messages',
'deliveries',
'task_snapshots',
'projects',
'threads',
'thread_claims',
'thread_opens',
]) {
const at = store.get(`SELECT max(at) at FROM ${table}`).at;
if (at) {
@@ -187,11 +199,61 @@ export class Broker {
role,
);
}
#threaded(business, role) {
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
}
#threadHolder(thread) {
return this.#store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
}
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
#own(s) {
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
}
// Who a message to `to` goes to now: the thread's holder when the message is in a thread of
// `to`, the role's otherwise. Null while nobody holds it, and the message waits.
#recipient(business, to, thread) {
const h = thread && this.#threaded(business, to) ? this.#threadHolder(thread) : this.#holder(business, to);
return h?.op === 'claim' ? h : null;
}
// The thread a new message belongs to (REQ-MSG-1). A thread run's message is in its thread.
// A message to a thread instance from anyone else is in the thread of the nearest message up
// its in_reply_to chain that has one, so the chief of staff's reply returns to the thread
// that sent the work. Messages are append-only and in_reply_to names an earlier message, so
// the chain has an end; `seen` stops the walk there and needs no depth constant.
#threadFor(s, to, inReplyTo) {
if (s.thread) return s.thread;
if (!this.#threaded(s.business, to)) return null;
// The user names the thread with thread.send, which checks the owner (REQ-USER-2).
if (s.human) fail('thread-required-use-thread-send');
const seen = new Set();
for (let at = inReplyTo ?? null; at !== null && !seen.has(at); ) {
seen.add(at);
const m = this.#store.get(
'SELECT m.in_reply_to, t.thread FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
at,
s.business,
);
if (m?.thread) {
if (this.#store.get('SELECT instance FROM threads WHERE id=?', m.thread).instance !== to) fail('thread-instance-mismatch');
return m.thread;
}
at = m?.in_reply_to ?? null;
}
// The code is the whole message a run sees (runner.mjs forwards codes only), so it says what to do.
fail('thread-required-reply-to-thread-message');
}
#thread(s, id) {
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
// REQ-USER-2: a user reaches only their own threads.
if (t.owner !== s.human) fail('thread-not-owned');
return t;
}
#agent(s) {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
const h = this.#holder(s.business, s.role);
const h = this.#own(s);
if (h?.op !== 'claim' || h.holder_run !== s.run) fail('not-holder');
return h;
}
@@ -204,11 +266,65 @@ export class Broker {
this.#sessions.set(cap, Object.freeze(s));
return cap;
}
// Row 121 (#1615): what the launcher needs to start, stop and recover thread sessions.
// Trusted launcher API, not a socket verb. Per thread: messages to the instance that no
// run has been delivered yet, messages delivered to the current holder and not yet read
// (a turn in progress), and the latest message, open and claim times. A message delivered
// to an earlier run and never read (it crashed mid-turn) isn't the holder's turn, so it
// doesn't keep the holder from idling out.
threadsState(business) {
this.#business(identifier(business));
return this.#threadRows('t.business=?', business);
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
// run about to be killed. This rechecks and releases in one synchronous call, so a message
// sent after it waits for the next holder. The launcher sends SIGTERM only when released.
threadIdle(record) {
keys(record, ['business', 'thread', 'run'], ['business', 'thread', 'run']);
this.#business(identifier(record.business));
const key = record.business + ':' + identifier(record.run);
const session = this.#runs.get(key) ?? fail('unknown-run');
if (this.#ended.has(key)) fail('run-ended');
// A run holds at most one thread, so this also refuses another thread and a non-thread run.
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
if (t?.holder !== record.run) fail('not-holder');
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
this.#store.transaction(() => this.#claimEnd(session, this.#threadHolder(t.thread), 'release', null));
return { released: true };
}
#threadRows(where, ...params) {
return this.#store
.all(
`SELECT t.id, t.instance,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id) AS last_message,
(SELECT max(at) FROM thread_opens WHERE thread=t.id) AS last_open,
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
FROM threads t LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
WHERE ${where} ORDER BY t.seq`,
...params,
)
.map((r) => ({
thread: r.id,
instance: r.instance,
pending: r.pending,
unread: r.unread,
lastMessage: r.last_message,
lastOpen: r.last_open,
lastClaim: r.claim_at,
holder: r.claim_op === 'claim' ? r.holder_run : null,
}));
}
// Trusted launcher API, deliberately NOT a socket verb. S6 supplies immutable launch records.
bindLaunch(record) {
keys(
record,
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime'],
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread'],
['business', 'role', 'run', 'harness'],
);
const b = this.#business(identifier(record.business));
@@ -216,20 +332,28 @@ export class Broker {
identifier(record.run);
if (!['pi', 'claude-code'].includes(record.harness)) fail('invalid-harness');
if (record.address !== undefined) string(record.address, 1024);
// Row 121: a thread instance's run is for one thread, and no other run names one.
if (this.#threaded(record.business, record.role)) {
if (record.thread === undefined) fail('thread-required');
if (!this.#store.get('SELECT 1 FROM threads WHERE id=? AND business=? AND instance=?', identifier(record.thread), record.business, record.role))
fail('thread-not-found');
} else if (record.thread !== undefined) fail('thread-not-allowed');
const key = record.business + ':' + record.run;
if (this.#runs.has(key)) fail('duplicate-run');
const session = { ...record, address: record.address ?? null, human: false };
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, human: false };
this.#secretCheck(record);
const old = this.#store.get(
"SELECT actor_role,body FROM events WHERE business=? AND kind='session.launched' AND subject=? ORDER BY seq LIMIT 1",
record.business,
record.run,
);
// A run without a thread keeps the body it had before row 121.
const body = {
harness: record.harness,
address: record.address ?? null,
pid: record.pid ?? null,
startTime: record.startTime ?? null,
...(record.thread === undefined ? {} : { thread: record.thread }),
};
if (old) {
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
@@ -256,7 +380,7 @@ export class Broker {
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
const h = this.#holder(session.business, session.role);
const h = this.#own(session);
const released = h?.op === 'claim' && h.holder_run === session.run;
if (released) this.#claimEnd(session, h, 'release', null);
this.#event(session, 'session.ended', { reason: record.reason, exitCode, released }, session.run);
@@ -797,6 +921,25 @@ export class Broker {
case 'role.claim': {
keys(a, []);
if (s.human) fail('agent-required');
// Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2).
if (s.thread) {
if (this.#store.get("SELECT 1 FROM thread_claims WHERE thread=? AND holder_run=? AND op='revoke' LIMIT 1", s.thread, s.run))
fail('run-revoked');
if (this.#threadHolder(s.thread)?.op === 'claim') fail('thread-already-held');
this.#store.run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by) VALUES(?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
s.thread,
'claim',
s.run,
s.harness,
s.address,
by,
);
this.#routeThread(s.thread);
return { role: s.role, run: s.run, thread: s.thread };
}
if (
this.#store.get(
"SELECT 1 FROM role_claims WHERE business=? AND role=? AND holder_run=? AND op='revoke' LIMIT 1",
@@ -1006,6 +1149,15 @@ export class Broker {
op === 'resolved'
? `resolved by ${by}: ${a.choice} (${JSON.parse(d.options).find((o) => o.key === a.choice).text})`
: `withdrawn by ${by}`;
// A close to a thread instance goes to the thread the raising run held. A decision
// raised before row 121 has no thread claim and stays unthreaded.
const thread = this.#threaded(s.business, d.raised_by_role)
? (this.#store.get(
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
s.business,
d.raised_by_run,
)?.thread ?? null)
: null;
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
@@ -1021,8 +1173,7 @@ export class Broker {
null,
`Decision ${d.id} on ${d.action} was ${outcome}.${a.note === undefined ? '' : `\nNote: ${a.note}`}`,
);
const h = this.#holder(s.business, d.raised_by_role);
this.#delivery(mid, 'routed', h?.op === 'claim' ? h : null);
this.#route(s.business, mid, d.raised_by_role, thread);
}
return { id: d.id, op };
}
@@ -1075,6 +1226,7 @@ export class Broker {
}
}
if (a.decision) this.#decision(s, a.decision);
const thread = this.#threadFor(s, a.to, a.in_reply_to);
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
@@ -1090,8 +1242,7 @@ export class Broker {
a.corrects ?? null,
a.body,
);
const h = this.#holder(s.business, a.to);
this.#delivery(mid, 'routed', h?.op === 'claim' ? h : null);
this.#route(s.business, mid, a.to, thread);
const request = s.human
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
: null;
@@ -1102,16 +1253,20 @@ export class Broker {
if (s.human) this.#human(s);
else if (!s.tester) this.#agent(s);
const role = s.human ? 'human' : s.role;
// A thread run receives its own thread's messages only (REQ-THREAD-1).
const rows = this.#store.all(
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) ORDER BY seq LIMIT 100",
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))" +
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.thread=?)' : '') +
' ORDER BY seq LIMIT 100',
s.business,
role,
...(s.thread ? [s.thread] : []),
);
for (const m of rows)
this.#delivery(
m.id,
'delivered',
s.human || s.tester ? null : this.#holder(s.business, s.role),
s.human || s.tester ? null : this.#own(s),
s.human || s.tester ? 'cli' : 'broker',
);
return rows.map((m) => ({ ...m, request: this.#request(s.business, m) }));
@@ -1128,7 +1283,7 @@ export class Broker {
);
if (!delivery || delivery.holder_run !== (s.run ?? null)) fail('not-recipient');
if (!this.#store.get("SELECT seq FROM deliveries WHERE message=? AND op='read'", m.id))
this.#delivery(m.id, 'read', s.human || s.tester ? null : this.#holder(s.business, s.role));
this.#delivery(m.id, 'read', s.human || s.tester ? null : this.#own(s));
return { read: true };
}
// Row 93 (#1587): the coder asks a reviewer role to review the task's latest pushed commit.
@@ -1363,6 +1518,125 @@ export class Broker {
// reader sessions only, like decisions. It writes no delivery or read row, so a reply it
// lists still reaches the next `mosaic talk`. An id naming no message in the business
// refuses message-not-found, as message.send does for in_reply_to; trail would return [].
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
// project from the business file; the rename to repos is a P19 follow-up.
case 'project.create': {
this.#human(s);
keys(a, ['name', 'repo'], ['name']);
if (!name(a.name)) fail('invalid-request');
if (a.repo !== undefined && !Object.hasOwn(b.projects ?? {}, identifier(a.repo))) fail('unknown-repo');
if (this.#store.get('SELECT 1 FROM projects WHERE business=? AND name=?', s.business, a.name)) fail('project-exists');
const pid = randomUUID();
this.#store.run(
'INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
pid,
this.#now(),
s.business,
a.name,
a.repo ?? null,
s.human,
);
return { id: pid };
}
case 'project.list': {
this.#human(s);
keys(a, []);
return this.#store.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business);
}
// A thread belongs to the user who made it and to the business's one thread instance.
case 'thread.create': {
this.#human(s);
keys(a, ['project', 'name'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found');
// The bus never picks between instances; the business file declares exactly one.
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
if (!instances.length) fail('thread-instance-missing');
if (instances.length > 1) fail('thread-instance-ambiguous');
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
tid,
this.#now(),
s.business,
a.project,
instances[0],
s.human,
a.name,
);
return { id: tid, instance: instances[0] };
}
case 'thread.list': {
this.#human(s);
keys(a, ['project']);
if (a.project !== undefined) identifier(a.project);
return this.#store.all(
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
(a.project === undefined ? '' : ' AND project=?') +
' ORDER BY seq',
s.business,
s.human,
...(a.project === undefined ? [] : [a.project]),
);
}
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
case 'thread.open': {
this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#thread(s, a.thread);
this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, s.human);
return { opened: t.id };
}
case 'thread.send': {
this.#human(s);
keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
const t = this.#thread(s, a.thread);
// Row 120's lists hold here as on message.send: the instance must hear from the human.
this.#checkMessaging(s.business, this.#sender(s), t.instance);
string(a.body, 32768);
if (a.class !== undefined && !['REQUEST', 'INFO', 'REACTION'].includes(a.class)) fail('invalid-class');
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid,
this.#now(),
s.business,
'human',
'human-cli',
t.instance,
a.class ?? 'INFO',
null,
null,
null,
a.body,
);
this.#route(s.business, mid, t.instance, t.id);
return { id: mid, request: this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': {
this.#human(s);
keys(a, ['thread', 'after', 'limit'], ['thread']);
const t = this.#thread(s, a.thread);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
let after = 0;
if (a.after !== undefined) {
after = this.#store.get(
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.thread=?',
identifier(a.after),
t.id,
)?.seq;
if (after === undefined) fail('message-not-found');
}
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
t.id,
after,
);
}
case 'replies': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['id'], ['id']);
@@ -1411,7 +1685,21 @@ export class Broker {
return { ...c, state, working, inputNeeded, stranded };
}
// Row 123 (#1617): a role.end revoke carries its reason in role_claims.reason and no decision.
// A thread claim is never revoked (row 121), so thread_claims has no reason.
#claimEnd(s, h, op, decision, reason = null) {
if (h.thread)
return this.#store.run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
h.thread,
op,
h.holder_run,
h.harness,
h.address,
s.run ?? s.human,
decision,
);
this.#store.run(
'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)',
this.#now(),
@@ -1446,6 +1734,7 @@ export class Broker {
}
// The message a review verb sends, routed like message.send's.
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
const thread = this.#threadFor(s, to, in_reply_to);
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid,
@@ -1460,8 +1749,12 @@ export class Broker {
null,
body,
);
const h = this.#holder(s.business, to);
this.#delivery(mid, 'routed', h?.op === 'claim' ? h : null);
this.#route(s.business, mid, to, thread);
}
// Row 121: a message names its thread (REQ-MSG-1) and goes to the holder that may have it.
#route(business, mid, to, thread) {
if (thread) this.#store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', mid, thread);
this.#delivery(mid, 'routed', this.#recipient(business, to, thread));
}
#delivery(mid, op, h, transport = null) {
this.#store.run(
@@ -1483,6 +1776,15 @@ export class Broker {
);
for (const m of rows) this.#delivery(m.id, 'routed', h);
}
// A new thread holder gets the thread's waiting messages to the instance.
#routeThread(thread) {
const h = this.#threadHolder(thread);
const rows = this.#store.all(
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.thread=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
thread,
);
for (const m of rows) this.#delivery(m.id, 'routed', h);
}
#trail(s, subject) {
const rows = new Map();
const add = (table, list) => {
+2
View File
@@ -6,7 +6,9 @@ if (process.argv.length !== 3) {
} else {
try {
const store = new Store(process.argv[2], { migrateSchema: true });
const report = store.migrationReport;
store.close();
for (const line of report) process.stdout.write(line + '\n');
process.stdout.write(`bus schema ${SCHEMA_VERSION} ready\n`);
} catch (e) {
process.stderr.write(`migration refused: ${e.message}\n`);
+93
View File
@@ -0,0 +1,93 @@
-- Row 121 (#1615): projects, threads and their claims. Additive: messages from before 3e stay unthreaded.
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
+5
View File
@@ -11,6 +11,8 @@ const LAUNCH_OPS = new Set([
'checkPush',
'recordPush',
'reviewFetch',
'threadsState',
'threadIdle',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -21,6 +23,9 @@ function launchOp(m) {
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args);
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
if (m.op === 'threadsState') return runtime.threadsState(m.business);
if (m.op === 'threadIdle') return runtime.threadIdle(m.record);
return runtime.credentialStatus(m.business, m.role);
}
// The host's request id comes back on the reply, so a late reply can't
+2
View File
@@ -119,6 +119,8 @@ export async function startBroker({
path,
bindLaunch,
endLaunch: (r) => broker.endLaunch(r),
threadsState: (business) => broker.threadsState(business),
threadIdle: (r) => broker.threadIdle(r),
identity: (cap) => broker.identity(cap),
authorizeLaunch,
checkPush,
+23 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5';
export const SCHEMA_VERSION = '3d';
export const SCHEMA_SHA256 = '21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050';
export const SCHEMA_VERSION = '3e';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -29,6 +29,15 @@ const migrations = [{
from: '3c',
to: '3d',
sql: readFileSync(new URL('./migrations/3c-3d.sql', import.meta.url), 'utf8'),
}, {
from: '3d',
to: '3e',
sql: readFileSync(new URL('./migrations/3d-3e.sql', import.meta.url), 'utf8'),
// Messages stay as written. Undelivered ones have no thread, so a thread run never receives them.
// The store doesn't know which roles are thread instances, so it reports every role's count.
report: (store) => store.all(
"SELECT business, to_role, count(*) AS n FROM messages m WHERE NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) GROUP BY business, to_role ORDER BY business, to_role",
).map((r) => `kept ${r.n} undelivered message(s) for ${r.business}/${r.to_role} without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send`),
}];
const timedTables = [
'events',
@@ -38,6 +47,10 @@ const timedTables = [
'messages',
'deliveries',
'task_snapshots',
'projects',
'threads',
'thread_claims',
'thread_opens',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -93,6 +106,7 @@ export class Store {
#lockStat;
#closed = false;
#inTransaction = false;
#report = [];
constructor(dataRoot, { migrateSchema = false } = {}) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot)) throw Error('unsafe-path');
const rootStat = lstatSync(dataRoot);
@@ -179,12 +193,17 @@ export class Store {
this.run("UPDATE meta SET value=? WHERE key='schema_version'", step.to);
this.run("UPDATE meta SET value=? WHERE key='schema_digest'", step.toDigest);
step.initialize?.(this);
this.#report.push(...(step.report?.(this) ?? []));
this.#db.exec("CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END");
if (schemaDigest(this.#db) !== step.toDigest) throw Error('schema-mismatch');
});
version = step.to;
}
}
// Lines from the last migrateSchema, for migrate-schema.mjs to print.
get migrationReport() {
return [...this.#report];
}
get(sql, ...args) {
return this.#db.prepare(sql).get(...args);
}
@@ -197,6 +216,8 @@ export class Store {
}
#verifyTimes() {
for (const table of timedTables) {
// A migration checks older schemas that lack later tables; the digest checks decide which must exist.
if (!this.get("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", table)) continue;
const badAt =
"length(at)<>24 OR at NOT GLOB '[0-9][0-9][0-9][0-9]-*' OR strftime('%Y-%m-%dT%H:%M:%fZ',at) IS NOT at";
const badRead =
+14 -7
View File
@@ -60,11 +60,14 @@ function setup(t) {
},
});
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// Row 121 (#1615): the assistant runs for one thread.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.reader = b.bindReader({ business: 'demo' });
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
@@ -169,11 +172,15 @@ test('REQ-ROLE-2: a reason is required, bounded, and never sent with a decision'
});
test('REQ-COS-1: the chief of staff cannot end its own run or the assistant\'s through role.end', (t) => {
const { call, lastClaim, allowed } = setup(t);
const { store, call, lastClaim, allowed } = setup(t);
for (const role of ['chief', 'assistant'])
assert.throws(() => call('chief', 'role.revoke', { role, reason: 'no' }), /role-end-refused/, role);
assert.deepEqual(lastClaim('chief'), { op: 'claim', decision: null, reason: null, by: 'chief-run' });
assert.deepEqual(lastClaim('assistant'), { op: 'claim', decision: null, reason: null, by: 'assistant-run' });
// Row 121 (#1615): the assistant's run holds its thread, so its claim is in thread_claims.
assert.deepEqual(
{ ...store.get('SELECT op, holder_run, by FROM thread_claims ORDER BY seq DESC LIMIT 1') },
{ op: 'claim', holder_run: 'assistant-run', by: 'assistant-run' },
);
// The refusal rolls its action.allowed back with the transaction.
assert.equal(allowed('role.end').length, 0);
// A role that is not held is refused as before, and logs nothing.
@@ -360,7 +367,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -380,11 +387,11 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.equal(old.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3c');
assert.throws(() => old.exec(REVOKE_NEITHER.replace('NULL,NULL)', "NULL,'a reason')")), /revoke needs a resolved decision$/);
old.close();
// A 3c database does not open as 3d until the explicit step runs.
// A 3c database does not open as current until the explicit steps run.
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -28,12 +28,12 @@ function legacy(t) {
return { root, source, path };
}
test('explicit 3b copy migration preserves evidence and opens as 3d; normal startup never migrates', t => {
test('explicit 3b copy migration preserves evidence and opens as 3e; normal startup never migrates', t => {
const { root, source } = legacy(t);
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
});
test('named migration registry chains 3b through 3d to a fixture-only future version in order', async t => {
test('named migration registry chains 3b through 3e to a fixture-only future version in order', async t => {
const { root } = legacy(t);
const { writeFileSync } = await import('node:fs');
const { pathToFileURL } = await import('node:url');
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3d to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3e';")
.replace('}];\nconst timedTables', `}, { from: '3d', to: '3e', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3f';")
.replace('}];\nconst timedTables', `}, { from: '3e', to: '3f', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3d ready/);
assert.match(migrated.stdout, /bus schema 3e ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+13 -3
View File
@@ -41,7 +41,14 @@ const definitions = {
scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'chief-of-staff'] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, roles, definitions },
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions,
projects: { stack: { root: '/nonexistent' } },
},
};
const ROUTE = 'decision-route-refused';
@@ -54,11 +61,14 @@ function setup(t) {
});
const b = new Broker({ store, businesses });
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// Row 121 (#1615): the assistant runs for one thread.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const raise = (who, action, extra = {}) =>
call(who, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
+14
View File
@@ -142,6 +142,13 @@ test('broker process: launch ops authorize role.launch, record refusals and end
const child = launch(t);
const data = join(root, 'data');
mkdirSync(data);
// Row 121 (#1615, REQ-LAUNCH-1): a thread for the launcher's thread view below, written
// before the child takes the store's writer lock. The human verbs need an assistant instance.
const side = new Store(data);
side.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', '2026-10-15T00:00:00.000Z', 'demo', 'work', null, 'jason');
side.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'th-1', '2026-10-15T00:00:00.000Z', 'demo', 'p-1', 'coder', 'jason', 'one');
side.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'jason');
side.close();
const ready = await ask(child, {
op: 'boot',
config: { dataRoot: data, businesses: config, launches: [], readers: [] },
@@ -173,6 +180,13 @@ test('broker process: launch ops authorize role.launch, record refusals and end
{ service: 'vikunja', date: '2099-01-01', state: 'valid' },
]);
assert.deepEqual((await ask(child, { op: 'credentialStatus', business: 'demo', role: 'pm' })).result, []);
// The launcher's thread view, through the same child.
assert.deepEqual((await ask(child, { op: 'threadsState', business: 'demo' })).result, [
{ thread: 'th-1', instance: 'coder', pending: 0, unread: 0, lastMessage: null, lastOpen: '2026-10-15T00:00:01.000Z', lastClaim: null, holder: null },
]);
// And its idle release (Dewey's F3, 28591): the op reaches the broker, which refuses a run
// that doesn't hold the thread.
assert.deepEqual(await ask(child, { op: 'threadIdle', record: { business: 'demo', thread: 'th-1', run: 'pm-1' } }), { ok: false, error: 'not-holder' });
const end = await ask(child, { op: 'endLaunch', record: { business: 'demo', run: 'pm-1', reason: 'stopped', exitCode: 0 } });
assert.deepEqual(end, { ok: true, result: { released: true } });
// The host's request id comes back on every launch-op and bind reply, refusals too.
+229
View File
@@ -0,0 +1,229 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
+30 -6
View File
@@ -64,21 +64,26 @@ function setup(t, businesses = demo()) {
});
const b = new Broker({ store, businesses });
const caps = {};
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// Row 121 (#1615): the assistant runs for one thread, and the user writes to it with thread.send.
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
for (const role of Object.keys(roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
b.request(caps[role], { verb: 'role.claim' });
}
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = b.bindTester({ business: 'demo' });
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const send = (from, to, extra = {}) =>
call(from, 'message.send', { to, body: from === 'tester' ? '[mos test] hello' : 'hello', ...extra });
from === 'human' && to === 'assistant'
? call(from, 'thread.send', { thread, body: 'hello', ...extra })
: call(from, 'message.send', { to, body: from === 'tester' ? '[mos test] hello' : 'hello', ...extra });
const count = (sql) => store.get(sql).n;
const refusals = () =>
store
.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq")
.map((e) => [e.actor_role, JSON.parse(e.body).code]);
return { b, store, caps, call, send, count, refusals };
return { b, store, caps, call, send, count, refusals, thread };
}
const RECIPIENT = 'message-recipient-refused';
@@ -118,8 +123,10 @@ test('an assistant send to any other role, or to the tester, is refused and logg
test('only the user and the chief of staff may send to the assistant; the tester is refused by default', (t) => {
const { send, store, count, refusals } = setup(t);
// The chief writes to the assistant in reply to the assistant's own message (row 121).
const opened = send('assistant', 'chief');
for (const from of ['human', 'chief']) {
const sent = send(from, 'assistant');
const sent = send(from, 'assistant', from === 'chief' ? { in_reply_to: opened.id } : {});
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', sent.id).to_role, 'assistant');
}
const messages = count('SELECT count(*) n FROM messages');
@@ -131,6 +138,21 @@ test('only the user and the chief of staff may send to the assistant; the tester
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='assistant' AND from_role='tester'"), 0);
});
// Row 121 round 2 (#1615, Dewey's F1 in 28591): thread.send is the user's only way to the
// assistant, so it runs the same check as message.send.
test('REQ-ASSIST-2 REQ-THREAD-1 thread.send to an instance that does not hear from the user is refused and stores nothing', (t) => {
const businesses = demo({
definitions: { ...definitions, assistant: { messaging: { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['chief-of-staff'] } } },
});
const { send, count, refusals } = setup(t, businesses);
const messages = count('SELECT count(*) n FROM messages');
assert.throws(() => send('human', 'assistant'), { code: SENDER });
assert.deepEqual(refusals(), [[null, SENDER]]);
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
assert.equal(count('SELECT count(*) n FROM message_threads'), 0, 'nothing filed in the thread');
assert.equal(count("SELECT count(*) n FROM events WHERE kind='human.input'"), 0, 'no instruction event');
});
test('a sender-side list alone refuses a send to a role that has no list', (t) => {
const { send, count, refusals } = setup(t);
send('scribe', 'pm');
@@ -182,7 +204,9 @@ test('a business with no role definitions at all is unlimited', (t) => {
const businesses = demo();
delete businesses.demo.definitions;
const { send, refusals } = setup(t, businesses);
for (const [from, to] of [['assistant', 'pm'], ['pm', 'assistant'], ['tester', 'assistant']]) send(from, to);
// A send to the assistant still answers a message in its thread (row 121).
const opened = send('assistant', 'pm');
for (const from of ['pm', 'tester']) send(from, 'assistant', { in_reply_to: opened.id });
assert.deepEqual(refusals(), []);
});
+3 -2
View File
@@ -516,7 +516,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
const count = (type) => db.prepare('SELECT count(*) n FROM sqlite_master WHERE type = ?').get(type).n;
for (const label of refused) assert.ok(tested.has(label), label);
assert.equal(count('table') - 1, 9);
assert.equal(count('trigger'), 39);
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
assert.equal(count('table') - 1, 14);
assert.equal(count('trigger'), 63);
assert.equal(count('view'), 5);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const here = (path) => new URL(path, import.meta.url);
const AT = '2026-10-15T00:00:00.000Z';
// [id, business, to_role, delivery ops]
const MESSAGES = [
['m-asst-waiting', 'demo', 'assistant', ['routed']],
['m-asst-delivered', 'demo', 'assistant', ['routed', 'delivered']],
['m-asst-read', 'demo', 'assistant', ['routed', 'delivered', 'read']],
['m-chief-waiting', 'demo', 'chief', []],
['m-human-waiting', 'demo', 'human', ['routed']],
['m-other-1', 'other', 'assistant', ['routed']],
['m-other-2', 'other', 'assistant', ['failed']],
];
const REPORT = [
'kept 1 undelivered message(s) for demo/assistant without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
'kept 1 undelivered message(s) for demo/chief without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
'kept 1 undelivered message(s) for demo/human without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
'kept 2 undelivered message(s) for other/assistant without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
];
// A 3d bus with messages in every delivery state, made by the engine pinned at 3d, plus an untouched copy.
async function database3d(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(path);
db.exec(readFileSync(here('./fixtures/schema-3b.sql'), 'utf8'));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.close();
chmodSync(path, 0o600);
// The engine pinned at 3d: current store.mjs with the 3d→3e step removed and the 3d schema.
const ddl3d = readFileSync(here('./fixtures/schema-3d.sql'), 'utf8');
assert.equal(createHash('sha256').update(ddl3d).digest('hex'), 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5');
const moduleRoot = join(root, 'engine-3d');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3d);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql'])
copyFileSync(here('../src/migrations/' + name), join(moduleRoot, 'src/migrations', name));
const { Store: Store3d } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const old = new Store3d(root, { migrateSchema: true });
assert.equal(old.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
old.transaction(() => {
for (const [id, business, to, ops] of MESSAGES) {
old.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
id, AT, business, 'human', 'human', to, 'info', 'before threads',
);
for (const op of ops)
old.run('INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,?,?)', id, AT, op, op === 'routed' ? null : 'old-run');
}
});
old.close();
const copy = join(root, 'bus-3d-copy.sqlite');
copyFileSync(path, copy);
return { root, path, copy };
}
const rows = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return {
messages: db.prepare('SELECT * FROM messages ORDER BY seq').all(),
deliveries: db.prepare('SELECT * FROM deliveries ORDER BY seq').all(),
};
} finally {
db.close();
}
};
test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and leaves messages from before threads as written', async (t) => {
const { root, path, copy } = await database3d(t);
const before = rows(path);
assert.equal(before.messages.length, MESSAGES.length);
// A 3d bus does not open as 3e until the explicit step runs.
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
assert.deepEqual(store.migrationReport, REPORT);
store.close();
assert.deepEqual(rows(path), before);
assert.deepEqual(rows(copy), before);
// Opening again needs no step and reports nothing.
const again = new Store(root, { migrateSchema: true });
assert.deepEqual(again.migrationReport, []);
again.close();
// A thread run of the instance never receives the old message, and the thread does not count it.
const current = new Store(root);
const b = new Broker({
store: current,
businesses: {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'chief', delivery: 'chief' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: {} },
projects: { stack: { root: '/nonexistent' } },
},
},
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const project = b.request(human, { verb: 'project.create', args: { name: 'stack work', repo: 'stack' } }).id;
const thread = b.request(human, { verb: 'thread.create', args: { project, name: 'after 3e' } }).id;
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
b.request(run, { verb: 'role.claim', args: {} });
assert.deepEqual(b.threadsState('demo').map((r) => [r.thread, r.holder, r.pending, r.unread]), [
[thread, 'assistant-run', 0, 0],
]);
assert.deepEqual(b.request(run, { verb: 'message.receive', args: {} }), []);
current.close();
assert.deepEqual(rows(path), before);
});
test('REQ-THREAD-1: a 3d bus with a changed schema is refused and keeps its version and tables', async (t) => {
const { root, path } = await database3d(t);
const db = new DatabaseSync(path);
db.exec('DROP TRIGGER messages_no_delete');
db.close();
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
const check = new DatabaseSync(path, { readOnly: true });
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3d');
assert.equal(check.prepare("SELECT count(*) n FROM sqlite_master WHERE name='threads'").get().n, 0);
check.close();
});
test('REQ-THREAD-1: the offline command prints the kept-message counts before the ready line', async (t) => {
const { root } = await database3d(t);
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3e ready', ''].join('\n'));
});
+490
View File
@@ -0,0 +1,490 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
// Row 121 (#1615): projects and threads in the bus. The assistant's messaging lists are the
// shipped roles/assistant.json's, so the chain below runs under the lists the stack runs.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const roles = {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const definitions = {
'chief-of-staff': {},
assistant: { messaging: shipped('assistant').messaging },
pm: {},
coder: {},
};
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions,
projects: { stack: { root: '/nonexistent' } },
...extra,
},
});
function setup(t, businesses = demo()) {
const root = mkdtempSync(join(tmpdir(), 'bus-threads-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({ store, businesses });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const caps = { human };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
return caps[name];
};
const claim = (name) => call(name, 'role.claim');
const count = (sql, ...args) => store.get(sql, ...args).n;
const threadOf = (message) => store.get('SELECT thread FROM message_threads WHERE message=?', message)?.thread ?? null;
return { b, store, caps, call, launch, claim, count, threadOf };
}
// Two threads in one project, each with its own assistant run holding it.
function twoThreads(t, businesses) {
const ctx = setup(t, businesses);
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
const B = ctx.call('human', 'thread.create', { project, name: 'beta' }).id;
for (const r of ['chief', 'pm', 'coder']) {
ctx.launch(r, r);
ctx.claim(r);
}
ctx.launch('a', 'assistant', { thread: A });
ctx.launch('b', 'assistant', { thread: B });
ctx.claim('a');
ctx.claim('b');
return { ...ctx, project, A, B };
}
const now = () => new Date().toISOString();
test('REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger', (t) => {
const { b, store, call, launch, claim, A, B } = twoThreads(t);
launch('a2', 'assistant', { thread: A });
assert.throws(() => claim('a2'), { code: 'thread-already-held' });
// The trigger refuses a second claim that skips the broker.
const second = () =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
now(),
'demo',
A,
'a2-run',
'pi',
'a2-run',
);
assert.throws(second, /thread already held/);
// Second case: one run holds one thread. a-run already holds A.
call('b', 'role.release');
assert.throws(
() =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
now(),
'demo',
B,
'a-run',
'pi',
'a-run',
),
/run already holds a thread/,
);
// A released thread can be claimed again by a new run; the claim is the thread's, not the role's.
launch('b2', 'assistant', { thread: B });
assert.deepEqual(claim('b2'), { role: 'assistant', run: 'b2-run', thread: B });
assert.equal(store.get("SELECT count(*) n FROM role_claims WHERE role='assistant'").n, 0, 'no role claim for the instance');
// endLaunch releases the thread claim.
b.endLaunch({ business: 'demo', run: 'b2-run', reason: 'idle' });
assert.equal(store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', B).op, 'release');
});
test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker', (t) => {
// A bare database from schema.sql, as schema.test.mjs does, so nothing but the triggers stands
// between a write and the tables.
const root = mkdtempSync(join(tmpdir(), 'bus-threads-sql-'));
const db = new DatabaseSync(join(root, 'bus.sqlite'));
t.after(() => {
db.close();
rmSync(root, { recursive: true, force: true });
});
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
const claim = (op, run_, extra = {}) =>
run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?)',
now(),
extra.business ?? 'demo',
extra.thread ?? 'th-1',
op,
run_,
'pi',
run_,
extra.reason ?? null,
null,
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-1',?,'demo','p-1','assistant','jason','one')", now());
run("INSERT INTO thread_opens(at,thread,by) VALUES(?,'th-1','jason')", now());
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
run("INSERT INTO message_threads(message,thread) VALUES('m-1','th-1')");
// A thread is in its project's business.
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-x',?,'other','p-1','assistant','jason','x')", now()),
/a thread belongs to its project's business/,
);
// A claim names the thread's business.
assert.throws(() => claim('claim', 'a-run', { business: 'other' }), /a thread claim names the thread's business/);
// Nothing releases or revokes a thread nobody holds.
assert.throws(() => claim('release', 'a-run'), /thread is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /thread is not held/);
claim('claim', 'a-run');
// Only the holder releases; a revoke needs a decision or a reason.
assert.throws(() => claim('release', 'b-run'), /only the holder releases; others revoke/);
assert.throws(() => claim('revoke', 'a-run'), /revoke needs a resolved decision or a reason/);
claim('revoke', 'a-run', { reason: 'stuck' });
// Every 3e table is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
const rows = {
projects: ["id='p-1'", 'name', "INSERT OR REPLACE INTO projects(seq,id,at,business,name,created_by) SELECT seq,id,at,business,name,created_by FROM projects WHERE id='p-1'"],
threads: ["id='th-1'", 'name', "INSERT OR REPLACE INTO threads(seq,id,at,business,project,instance,owner,name) SELECT seq,id,at,business,project,instance,owner,name FROM threads WHERE id='th-1'"],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by) SELECT seq,at,business,thread,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread) VALUES('m-1','th-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,by) SELECT seq,at,thread,by FROM thread_opens WHERE seq=1'],
};
for (const [table, [where, col, replace]] of Object.entries(rows)) {
const only = new RegExp(`${table} is append-only`);
assert.throws(() => db.exec(`UPDATE ${table} SET ${col}=${col} WHERE ${where}`), only, `${table} UPDATE`);
assert.throws(() => db.exec(`DELETE FROM ${table} WHERE ${where}`), only, `${table} DELETE`);
assert.throws(() => db.exec(replace), only, `${table} INSERT OR REPLACE`);
}
});
test('REQ-MSG-1: a message to or from the assistant names its thread, or is refused', (t) => {
const { b, store, call, launch, count, threadOf, A, B } = twoThreads(t);
const before = count('SELECT count(*) n FROM messages');
// The chief writes to the assistant only in reply to thread work.
assert.throws(() => call('chief', 'message.send', { to: 'assistant', body: 'hi' }), {
code: 'thread-required-reply-to-thread-message',
});
// The user writes to a thread with thread.send, which checks the owner.
assert.throws(() => call('human', 'message.send', { to: 'assistant', body: 'hi' }), {
code: 'thread-required-use-thread-send',
});
assert.equal(count('SELECT count(*) n FROM messages'), before, 'a refused send stores nothing');
// A run of the instance names its thread at bind, and no other run names one.
assert.throws(() => launch('x', 'assistant'), { code: 'thread-required' });
assert.throws(() => launch('y', 'assistant', { thread: 'no-such-thread' }), { code: 'thread-not-found' });
assert.throws(() => launch('z', 'pm', { thread: A }), { code: 'thread-not-allowed' });
// Accepted messages: the user's, the assistant's to the user and the chief, a decision close.
const asked = call('human', 'thread.send', { thread: A, body: 'look at the build' });
const toUser = call('a', 'message.send', { to: 'human', body: 'on it', in_reply_to: asked.id });
const toChief = call('b', 'message.send', { to: 'chief', body: 'need a coder', class: 'REQUEST' });
assert.equal(threadOf(asked.id), A);
assert.equal(threadOf(toUser.id), A);
assert.equal(threadOf(toChief.id), B);
// The decision close to the assistant goes to the thread the raising run held.
const d = call('b', 'decision.raise', {
action: 'spend',
target: 'demo',
question: 'spend?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'no' });
const close = store.get("SELECT id FROM messages WHERE class='DECISION' AND decision=?", d.id);
assert.equal(threadOf(close.id), B);
assert.equal(
count(
"SELECT count(*) n FROM messages m WHERE 'assistant' IN (m.to_role, m.from_role) AND NOT EXISTS(SELECT 1 FROM message_threads t WHERE t.message=m.id)",
),
0,
'every message to or from the assistant has its thread',
);
// The store refuses a thread on a message the instance is not party to.
const pmOnly = call('pm', 'message.send', { to: 'coder', body: 'unrelated' });
assert.throws(
() => store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', pmOnly.id, A),
/to or from the thread's instance/,
);
void b;
});
test('REQ-MSG-1: the chief of staff\'s reply comes back to the thread that sent the work', (t) => {
const { call, threadOf, A, B } = twoThreads(t);
// Each thread asks the chief, who hands the work to the coder and answers when the coder does.
const run = (who) => {
const ask = call(who, 'message.send', { to: 'chief', body: `work for ${who}`, class: 'REQUEST' });
return ask;
};
const askA = run('a');
const askB = run('b');
// The assistant's messages reach the chief through the broker; the trigger only binds messages to the instance.
const got = call('chief', 'message.receive').map((m) => m.id);
assert.deepEqual(got, [askA.id, askB.id]);
const assignB = call('chief', 'message.send', { to: 'coder', body: 'do B', class: 'ASSIGNMENT', in_reply_to: askB.id });
const assignA = call('chief', 'message.send', { to: 'coder', body: 'do A', class: 'ASSIGNMENT', in_reply_to: askA.id });
const doneA = call('coder', 'message.send', { to: 'chief', body: 'A done', class: 'RESULT', in_reply_to: assignA.id });
const doneB = call('coder', 'message.send', { to: 'chief', body: 'B done', class: 'RESULT', in_reply_to: assignB.id });
const replyB = call('chief', 'message.send', { to: 'assistant', body: 'B is done', class: 'RESULT', in_reply_to: doneB.id });
const replyA = call('chief', 'message.send', { to: 'assistant', body: 'A is done', class: 'RESULT', in_reply_to: doneA.id });
assert.equal(threadOf(replyA.id), A);
assert.equal(threadOf(replyB.id), B);
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [replyA.id]);
assert.deepEqual(call('b', 'message.receive').map((m) => m.id), [replyB.id]);
// A long chain that never loops: the walk follows it to the end, with no depth limit.
let at = call('a', 'message.send', { to: 'chief', body: 'long one', class: 'REQUEST' }).id;
for (let i = 0; i < 400; i++)
at = call(i % 2 ? 'chief' : 'coder', 'message.send', {
to: i % 2 ? 'coder' : 'chief',
body: `step ${i}`,
in_reply_to: at,
}).id;
const far = call('chief', 'message.send', { to: 'assistant', body: 'long one done', in_reply_to: at });
assert.equal(threadOf(far.id), A);
// A coder message with no threaded ancestor breaks the chain, and the refusal says how to recover.
const loose = call('coder', 'message.send', { to: 'chief', body: 'unrelated', class: 'INFO' });
assert.throws(() => call('chief', 'message.send', { to: 'assistant', body: 'fyi', in_reply_to: loose.id }), {
code: 'thread-required-reply-to-thread-message',
});
});
test('REQ-MSG-1: a reply can\'t carry one thread instance\'s thread to another thread instance', (t) => {
// thread.create refuses a business with two thread instances, so the second instance's thread
// is made by hand. The trigger would refuse the message_threads row too; the broker refuses first,
// with a code that says why.
const helper = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } };
const { store, call, launch, claim, count } = setup(t, demo({ roles: { ...roles, helper } }));
const project = call('human', 'project.create', { name: 'two instances' }).id;
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']])
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, now(), 'demo', project, instance, 'jason', id);
launch('chief', 'chief');
claim('chief');
launch('a', 'assistant', { thread: 'th-a' });
claim('a');
const ask = call('a', 'message.send', { to: 'chief', body: 'work', class: 'REQUEST' });
const before = count('SELECT count(*) n FROM messages');
assert.throws(() => call('chief', 'message.send', { to: 'helper', body: 'done', in_reply_to: ask.id }), {
code: 'thread-instance-mismatch',
});
assert.equal(count('SELECT count(*) n FROM messages'), before);
// The same reply to the thread's own instance is fine.
call('chief', 'message.send', { to: 'assistant', body: 'done', in_reply_to: ask.id });
});
test('REQ-THREAD-1 REQ-USER-2: one thread\'s messages never reach another thread\'s holder', (t) => {
const { store, call, threadOf, A, B } = twoThreads(t);
const toA = call('human', 'thread.send', { thread: A, body: 'for A' });
const toB = call('human', 'thread.send', { thread: B, body: 'for B' });
assert.deepEqual(call('b', 'message.receive').map((m) => m.id), [toB.id]);
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [toA.id]);
assert.equal(threadOf(toA.id), A);
// A delivery of A's message to B's holder that skips the broker is refused by the store.
const toA2 = call('human', 'thread.send', { thread: A, body: 'again for A' });
for (const op of ['routed', 'delivered'])
assert.throws(
() =>
store.run(
'INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)',
toA2.id,
now(),
op,
'b-run',
'pi',
null,
),
/only to its thread's holder/,
op,
);
// B's holder can't read A's message either.
assert.throws(() => call('b', 'message.read', { id: toA.id }), { code: 'not-recipient' });
});
test('REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it', (t) => {
const { b, call, launch, claim, A, B } = twoThreads(t);
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' });
const waiting = call('human', 'thread.send', { thread: A, body: 'while idle' });
const state = () => Object.fromEntries(b.threadsState('demo').map((s) => [s.thread, s]));
assert.equal(state()[A].pending, 1);
assert.equal(state()[A].holder, null);
assert.equal(state()[B].holder, 'b-run');
assert.equal(state()[B].pending, 0);
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.equal(state()[A].holder, 'a2-run');
assert.deepEqual(call('a2', 'message.receive').map((m) => m.id), [waiting.id]);
assert.equal(state()[A].pending, 0);
assert.equal(state()[A].unread, 1, 'delivered and not read: a turn in progress');
call('a2', 'message.read', { id: waiting.id });
assert.equal(state()[A].unread, 0);
// An open is recorded for the launcher, with no message.
assert.equal(state()[B].lastOpen, null);
call('human', 'thread.open', { thread: B });
assert.ok(state()[B].lastOpen);
});
// Dewey's F3 (28591): the launcher stops an idle thread's run only through threadIdle, which
// rechecks the thread and releases it in one call.
test('REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder', (t) => {
const { b, call, launch, claim, A, B } = twoThreads(t);
const idle = (run, thread = A) => b.threadIdle({ business: 'demo', thread, run });
const state = () => Object.fromEntries(b.threadsState('demo').map((s) => [s.thread, s]));
const sent = call('human', 'thread.send', { thread: A, body: 'for A' });
assert.deepEqual(idle('a-run'), { released: false, pending: 1, unread: 0 });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [sent.id]);
assert.deepEqual(idle('a-run'), { released: false, pending: 0, unread: 1 });
call('a', 'message.read', { id: sent.id });
// Only the thread's holder, for its own thread.
assert.throws(() => idle('b-run'), { code: 'not-holder' });
assert.throws(() => idle('a-run', B), { code: 'not-holder' });
assert.throws(() => idle('chief-run'), { code: 'not-holder' });
assert.throws(() => idle('nobody-run'), { code: 'unknown-run' });
assert.equal(state()[A].holder, 'a-run', 'a refusal releases nothing');
assert.deepEqual(idle('a-run'), { released: true });
assert.equal(state()[A].holder, null);
assert.equal(state()[B].holder, 'b-run');
// Once released, the run can't take the thread's next message; it waits for the next holder.
assert.throws(() => idle('a-run'), { code: 'not-holder' });
const later = call('human', 'thread.send', { thread: A, body: 'later for A' });
assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' });
assert.equal(state()[A].pending, 1);
assert.equal(b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' }).released, false);
assert.throws(() => idle('a-run'), { code: 'run-ended' });
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.deepEqual(call('a2', 'message.receive').map((m) => m.id), [later.id]);
});
// A run that ends with a message delivered and unread strands it, as row 80 does for a role.
// It mustn't also keep every later holder of the thread from idling out.
test('REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn\'t hold up the next holder', (t) => {
const { b, call, launch, claim, A } = twoThreads(t);
const state = () => b.threadsState('demo').find((s) => s.thread === A);
const stranded = call('human', 'thread.send', { thread: A, body: 'taken, then the run died' });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [stranded.id]);
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'crash' });
assert.deepEqual([state().pending, state().unread, state().holder], [0, 0, null], 'nothing counts with no holder');
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.deepEqual([state().pending, state().unread], [0, 0], 'nor for the next holder');
assert.deepEqual(call('a2', 'message.receive'), [], 'not redelivered');
assert.deepEqual(b.threadIdle({ business: 'demo', thread: A, run: 'a2-run' }), { released: true });
});
// Dewey's F2 in 28591, documented rather than supported: a thread run is stopped, not revoked.
test('REQ-ROLE-2 REQ-THREAD-1: an approved role.revoke never reaches a thread run, and the thread stays held', (t) => {
const { store, call, count, A } = twoThreads(t);
const d = call('pm', 'decision.raise', {
action: 'role.revoke',
target: 'assistant',
question: 'Stop the assistant?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'yes',
blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
// The assistant has no role_claims row, so the decision names no holder run, and using it refuses.
assert.throws(() => call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), /decision-mismatch/);
assert.equal(count("SELECT count(*) n FROM role_claims WHERE role='assistant'"), 0);
assert.deepEqual({ ...store.get('SELECT op, holder_run FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', A) }, {
op: 'claim',
holder_run: 'a-run',
});
});
test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
const { store, call, project, A } = twoThreads(t);
const other = 'thread-of-someone-else';
store.run(
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other,
now(),
'demo',
project,
'assistant',
'someone-else',
'theirs',
);
for (const [verb, args] of [
['thread.open', { thread: other }],
['thread.send', { thread: other, body: 'hi' }],
['thread.messages', { thread: other }],
])
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
assert.throws(() => call('human', 'thread.send', { thread: 'nope', body: 'hi' }), { code: 'thread-not-found' });
assert.deepEqual(
call('human', 'thread.list').map((x) => x.id),
[A, store.get("SELECT id FROM threads WHERE name='beta'").id],
'the list shows the user\'s own threads only',
);
// Agents have no thread verbs.
assert.throws(() => call('pm', 'thread.list'), { code: 'human-required' });
});
test('REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one', (t) => {
const none = demo({ roles: { ...roles, assistant: { ...roles.assistant, definition: 'pm' } } });
const a = setup(t, none);
const p1 = a.call('human', 'project.create', { name: 'p' }).id;
assert.throws(() => a.call('human', 'thread.create', { project: p1, name: 'x' }), { code: 'thread-instance-missing' });
const two = demo({ roles: { ...roles, helper: { ...roles.assistant } } });
const c = setup(t, two);
const p2 = c.call('human', 'project.create', { name: 'p' }).id;
assert.throws(() => c.call('human', 'thread.create', { project: p2, name: 'x' }), { code: 'thread-instance-ambiguous' });
assert.equal(a.count('SELECT count(*) n FROM threads'), 0);
assert.equal(c.count('SELECT count(*) n FROM threads'), 0);
});
test('REQ-ORG-2: projects and threads are named once, and the repo names a business project', (t) => {
const { call, project, count } = twoThreads(t);
assert.throws(() => call('human', 'project.create', { name: 'stack work' }), { code: 'project-exists' });
assert.throws(() => call('human', 'project.create', { name: 'n', repo: 'missing' }), { code: 'unknown-repo' });
assert.throws(() => call('human', 'project.create', { name: 'two\nlines' }), { code: 'invalid-request' });
assert.throws(() => call('human', 'thread.create', { project, name: 'alpha' }), { code: 'thread-exists' });
assert.throws(() => call('human', 'thread.create', { project: 'nope', name: 'x' }), { code: 'project-not-found' });
assert.deepEqual(
call('human', 'project.list').map(({ name, repo }) => ({ name, repo })),
[{ name: 'stack work', repo: 'stack' }],
);
assert.equal(count('SELECT count(*) n FROM projects'), 1);
});
test('REQ-THREAD-2: thread.messages returns the thread\'s history in pages and writes nothing', (t) => {
const { store, call, A, B } = twoThreads(t);
const sent = [];
for (let i = 0; i < THREAD_PAGE + 3; i++) sent.push(call('human', 'thread.send', { thread: A, body: `m${i}` }).id);
call('human', 'thread.send', { thread: B, body: 'other thread' });
const writes = () => store.get('SELECT (SELECT count(*) FROM deliveries) + (SELECT count(*) FROM events) n').n;
const before = writes();
const page = call('human', 'thread.messages', { thread: A });
assert.deepEqual(page.map((m) => m.id), sent.slice(0, THREAD_PAGE));
const rest = call('human', 'thread.messages', { thread: A, after: page.at(-1).id });
assert.deepEqual(rest.map((m) => m.id), sent.slice(THREAD_PAGE));
assert.deepEqual(call('human', 'thread.messages', { thread: A, limit: 2 }).map((m) => m.id), sent.slice(0, 2));
assert.equal(writes(), before, 'reading the history writes nothing');
for (const limit of [0, 201, 1.5, '5'])
assert.throws(() => call('human', 'thread.messages', { thread: A, limit }), { code: 'invalid-request' }, String(limit));
});
+9
View File
@@ -100,6 +100,15 @@ instance whose definition holds `role.launch` within-role; otherwise the
file refuses. Only that instance keeps `role.launch`. Every other instance
loses it, so for them it's gated.
`launch.threadIdleSeconds` (optional, row 121) is how long a thread's
session runs with nothing pending and no new message or open before the
launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in
`src/business.mjs`). A set value is a positive integer with no upper bound;
anything else refuses the file. The key is accepted whether or not the file
declares a thread instance. A thread instance is one whose definition is
`assistant`. The launcher starts it once per thread, when the thread has a
pending message or was opened, and never on its own.
`plugins` (optional) switches on plug-in modules, one key per kind from
`packages/plugins/src/kinds.mjs`, each a list of module names matching
`^[a-z][a-z0-9-]*$` with no name twice. An unknown kind or a bad name
+7 -2
View File
@@ -15,6 +15,9 @@ import {
} from "./util.mjs";
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
@@ -115,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -130,7 +133,9 @@ function checkLaunch(launch, roles, definitions, file) {
const models=checkModels(launch.models,`${where}.models`);
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
return { by: launch.by, instances, models, limits };
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
return { by: launch.by, instances, models, limits, threadIdleSeconds };
}
// Validate a parsed business document read from `file`. `rolesDir` is where
+15 -1
View File
@@ -5,6 +5,7 @@ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
function refuses(fn, pattern, exitCode = 2) {
@@ -39,7 +40,7 @@ test("the fixture business validates and comes back frozen", () => {
assert.equal(business.roles.reviewer.holder, null);
assert.equal(business.roles.coder.vars["limits.network"], "none");
assert.equal(business.definitions.pm.roleVersion, 2);
assert.deepEqual(business.launch, launchDoc());
assert.deepEqual(business.launch, { ...launchDoc(), threadIdleSeconds: THREAD_IDLE_SECONDS });
assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea));
assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError);
});
@@ -134,6 +135,19 @@ test("launch", () => {
assert.deepEqual(check((d) => { d.launch.limits = {total:0}; }).launch.limits, {total:0});
});
// REQ-THREAD-2 (row 121, test 10): an absent threadIdleSeconds is the one
// exported default, a set value is used, and anything but a positive integer
// refuses. No upper bound (Q150).
test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses", () => {
assert.equal(THREAD_IDLE_SECONDS, 900);
assert.equal(check().launch.threadIdleSeconds, 900);
assert.equal(check((d) => { d.launch.threadIdleSeconds = 60; }).launch.threadIdleSeconds, 60);
for (const bad of [0, -1, 1.5, "900", null]) {
refuses(() => check((d) => { d.launch.threadIdleSeconds = bad; }), /launch\.threadIdleSeconds must be a positive integer/);
}
assert.equal(check((d) => { d.launch.threadIdleSeconds = Number.MAX_SAFE_INTEGER; }).launch.threadIdleSeconds, Number.MAX_SAFE_INTEGER);
});
test("loadBusiness: file checks", () => {
const root = tmp();
const dir = join(root, "config");
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
// Decision 104's list plus role.launch (decision 139) plus role.end and
@@ -58,7 +59,7 @@ test("the role file loads and resolves through loadBusiness", () => {
assert.equal(record.definition, "chief-of-staff");
assert.equal(record.contract, join(REPO_ROLES, "chief-of-staff.md"));
assert.equal(record.tracker, null);
assert.deepEqual(record.launch, launchDoc(INSTANCE));
assert.deepEqual(record.launch, { ...launchDoc(INSTANCE), threadIdleSeconds: THREAD_IDLE_SECONDS });
});
test("the shipped file is exactly the approved list", () => {
+43
View File
@@ -337,6 +337,49 @@ the recorded start time and that its command line is the runner's, then
sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
exits 0; an unknown run exits 2.
### Thread sessions (row 121, #1615)
A thread instance is an instance whose role definition is `assistant`
(`packages/bus/README.md`, "Projects and threads"). It runs once per
thread and never on its own: a `launch` request or `--pm` for it refuses
`thread-required`, and a thread launch of any other instance refuses
`thread-not-allowed`. `instance-running` counts a thread instance per
thread, so two threads run at once when the limits have room.
Every `threadTickMs` (2 s) the launcher asks the broker for the business's
threads (`threadsState`) and acts on each one:
- A thread with no running session starts one when a message to its
instance waits undelivered, or when `thread.open` came after its last
claim (REQ-LAUNCH-1). The start runs the checks in the table above, as
the human, so a full limit refuses `capacity-full` and the next tick
tries again. A refusal is logged once per code per thread, not once per
tick. Each new message or open starts at most one session: a session
that ends without taking the work waits for the next message or open.
- A running thread session stops once the thread has had no message or
open for `launch.threadIdleSeconds` (`packages/business/README.md`) and
the run is between turns. The broker's `threadIdle` decides that from
the store, not from the tick's snapshot: it releases the claim only with
nothing delivered and unread and nothing waiting. If a message came in
after the snapshot, nothing is released and the session keeps running.
After a release the launch log gets a `thread-idle` line, and the runner
gets SIGTERM, as `mosaic stop` sends it. An idle thread has no session,
so it counts against no limit (REQ-THREAD-2).
A thread run's session directory isn't in its run directory. It is
`threads/<business>/<thread>/session/` under the data root, kept across
runs (`packages/seat/README.md`, "Thread stores"), so the next run for the
thread resumes the harness session (REQ-THREAD-2). The launcher checks the
store before anything else; a thread id that isn't a plain id, or an unsafe
directory, refuses `thread-store-refused` and launches nothing. Launch,
refusal and end lines for a thread run carry `thread`, and so does its
`sessions.json` entry, so a host that starts after a crash binds the run
again with its thread and ends it as `host-lost`.
Thread runs of one instance share its workspace,
`workspaces/<business>/<instance>/`. The clone check runs at each start,
so it can fetch while another thread's session is running.
### Git socket
The launcher also serves `<dataRoot>/bus-host/git.sock` (0600,
+135 -16
View File
@@ -39,6 +39,29 @@
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
// the sandbox binds for a role that holds git.push.working or
// review.verdict; session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
// role definition is the assistant) runs once per thread, never on its own:
// a launch from a session or --pm refuses thread-required. Every tick
// (threadTickMs) the host asks the broker for the business's threads
// (launch op threadsState) and:
//
// - starts a session for a thread with no running session that has an
// undelivered message to its instance, or an open newer than its last
// claim (REQ-LAUNCH-1). The start goes through check(), so it refuses
// as any launch does, and the next tick tries again;
// - stops a thread session once the thread has had no message or open
// for launch.threadIdleSeconds, the way close() stops a run. The broker
// decides (launch op threadIdle): it releases the thread only when the
// run is between turns, with nothing delivered and unread and nothing
// waiting, and the SIGTERM goes only after it releases. A message sent
// after the snapshot gets a turn from the same run or waits for the
// next one, instead of going to the stopping run. An idle thread holds
// no session and counts against no limit.
//
// A thread run's sessionDir is the thread's store (session.mjs
// threadStore()), so the next run for the thread resumes the harness
// session (REQ-THREAD-2).
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
@@ -60,6 +83,7 @@ import {
runnerOf,
SESSION_CREDENTIALS,
sessionEnv,
threadStore,
sessionExit,
spawnSession,
workspaceDir,
@@ -75,6 +99,10 @@ import { CliError } from "./errors.mjs";
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
const STOP_TIMEOUT_MS = 30000;
const LINE_MAX = 4096;
// How often the host asks for the threads' state; a poll period, not a limit.
const THREAD_TICK_MS = 2000;
// The broker's thread instance definition (packages/bus/src/broker.mjs).
const THREAD_DEFINITION = "assistant";
// A refusal the host decided; `code` goes back to the caller and to the broker.
class Refusal extends Error {
@@ -103,8 +131,8 @@ export function claudeVersion(env = process.env) {
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. adapters, bwrap, readOnly, sessionDefaults, versions,
// stopTimeoutMs and git (createGitSocket options) exist for the tests; the
// command line never sets them.
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
// the tests; the command line never sets them.
export function createLauncher({
host,
system,
@@ -116,6 +144,7 @@ export function createLauncher({
sessionDefaults = {},
versions = null,
stopTimeoutMs = STOP_TIMEOUT_MS,
threadTickMs = THREAD_TICK_MS,
git = {},
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
@@ -137,6 +166,11 @@ export function createLauncher({
let chain = Promise.resolve();
let closed = false;
let server = null;
let ticker = null;
let ticking = false;
const stopping = new Set();
const refusedThreads = new Map();
const startedFor = new Map();
const sockets = new Set();
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
@@ -155,12 +189,16 @@ export function createLauncher({
}
// Steps 2 to 8. Returns what the launch needs. Only `--pm` may name
// launch.by itself.
function check(instance, { pm = false } = {}) {
// launch.by itself. A thread instance launches only for a thread, and
// nothing else launches for one.
function check(instance, { pm = false, thread = null } = {}) {
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
if (registry.running(b.id, instance)) throw new Refusal("instance-running");
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
if (registry.running(b.id, instance, thread)) throw new Refusal("instance-running");
let resolved, model;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
@@ -181,7 +219,17 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model }) {
async function start({ instance, launchedBy, resolved, model, thread = null }) {
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
let store = null;
if (thread) {
try {
store = threadStore(dataRoot, b.id, thread);
} catch (e) {
throw new Refusal("thread-store-refused", e.message);
}
}
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
// The clones come before the run, so one that can't be made leaves no
// run behind. The host's git treats each clone as hostile, and its
@@ -201,10 +249,10 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
({resolved,model}=check(instance,{pm:instance===b.launch.by}));
({resolved,model}=check(instance,{pm:instance===b.launch.by,thread}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
@@ -228,6 +276,7 @@ export function createLauncher({
...sessionDefaults,
business: b.id,
instance,
...(thread ? { thread } : {}),
run,
launchedBy,
harness: model.harness,
@@ -252,6 +301,7 @@ export function createLauncher({
launchSocket: resolved.launch ? launchSocketPath(dataRoot) : null,
gitSocket: git,
claudeBin: env.MOSAIC_CLAUDE_BIN || null,
threadStore: store,
readOnly,
};
const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
@@ -265,10 +315,10 @@ export function createLauncher({
const outerStart = startTimeOf(child.pid);
const runner = await runnerOf(child);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
bound = true;
entry = {
business: b.id, instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
};
registry.add(entry);
@@ -285,18 +335,85 @@ export function createLauncher({
try {
await endRun(run, reason, signal ? null : code);
registry.remove(b.id, run);
record({ event: "end", instance, run, reason, exitCode: code, signal });
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal });
log(`run ${run} (${instance}) ended: ${reason}`);
} catch (e) {
log(`run ${run}: ${e.message}`);
} finally {
children.delete(run);
stopping.delete(run);
}
});
record({ event: "launch", instance, run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
// Row 121: one tick of thread starts and idle stops. A tick that is still
// running when the next is due skips it.
async function tick() {
if (ticking || closed) return;
ticking = true;
try {
let threads;
try {
threads = await host.op({ op: "threadsState", business: b.id });
} catch (e) {
log(`threadsState refused: ${e.code ?? e.message}`);
return;
}
const now = Date.now();
const ms = (at) => (at ? Date.parse(at) : 0);
for (const t of threads) {
if (closed) return;
const s = registry.running(b.id, t.instance, t.thread);
if (s) {
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
if (!stopping.has(s.run) && now - last >= b.launch.threadIdleSeconds * 1000) {
let idle;
try {
idle = await host.op({ op: "threadIdle", record: { business: b.id, thread: t.thread, run: s.run } });
} catch (e) {
log(`run ${s.run}: threadIdle refused: ${e.code ?? e.message}`);
continue;
}
// A turn in progress, or a message since the snapshot: the run keeps going.
if (!idle.released) continue;
stopping.add(s.run);
record({ event: "thread-idle", instance: t.instance, thread: t.thread, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
continue;
}
const opened = ms(t.lastOpen) > ms(t.lastClaim);
if (t.holder || !(t.pending > 0 || opened)) continue;
// One start per message or open: a session that ended without taking
// the work (it died before its claim, say) waits for the next one
// rather than start again every tick. A refused start isn't a start.
const signature = `${t.lastMessage}|${t.lastOpen}`;
if (startedFor.get(t.thread) === signature) continue;
await serialize(async () => {
if (closed || registry.running(b.id, t.instance, t.thread)) return;
try {
await start({ instance: t.instance, launchedBy: b.human, thread: t.thread, ...check(t.instance, { thread: t.thread }) });
startedFor.set(t.thread, signature);
refusedThreads.delete(t.thread);
} catch (e) {
const code = e instanceof Refusal ? e.code : "launch-failed";
// Logged once per code, not once per tick.
if (refusedThreads.get(t.thread) === code) return;
refusedThreads.set(t.thread, code);
log(`thread ${t.thread} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
}
});
}
} finally {
ticking = false;
}
}
async function endRun(run, reason, exitCode) {
try {
await host.op({ op: "endLaunch", record: { business: b.id, run, reason, exitCode } });
@@ -376,7 +493,7 @@ export function createLauncher({
while (startTimeOf(s.pid) === s.startTime && Date.now() < end) await new Promise((r) => setTimeout(r, 20));
}
try {
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime });
await host.bindLaunch({ business: b.id, role: s.instance, run: s.run, harness: s.harness, pid: s.pid, startTime: s.startTime, ...(s.thread ? { thread: s.thread } : {}) });
} catch (e) {
// A host that died after ending the run but before updating sessions.json.
if (e.code === "run-ended") log(`run ${s.run} (${s.instance}) from an earlier host was already ended`);
@@ -384,7 +501,7 @@ export function createLauncher({
continue;
}
await endRun(s.run, "host-lost", null);
record({ event: "end", instance: s.instance, run: s.run, reason: "host-lost", exitCode: null, signal: null });
record({ event: "end", instance: s.instance, ...(s.thread ? { thread: s.thread } : {}), run: s.run, reason: "host-lost", exitCode: null, signal: null });
log(`run ${s.run} (${s.instance}) from an earlier host ended: host-lost`);
}
leftover = [];
@@ -436,12 +553,14 @@ export function createLauncher({
});
chmodSync(path, 0o600);
await gitSocket.listen();
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
// stops the broker, so endLaunch still works.
async function close() {
closed = true;
if (ticker) clearInterval(ticker);
if (server) {
// server.close waits for every connection, and a client that never
// ends its side (or never sends) would hold it; so they go first.
@@ -479,5 +598,5 @@ export function createLauncher({
}
host.beforeClose(close);
return { business: b, registry, listen, launchPm, fromSession, close };
return { business: b, registry, listen, launchPm, fromSession, tick, close };
}
+21
View File
@@ -0,0 +1,21 @@
// A stand-in harness turn for the thread launch tests (row 121, #1615). It
// keeps a history in $MOSAIC_SESSION_DIR, which for a thread run is the
// thread's store, and a transcript per turn under $HOME/.claude/projects.
// It answers with the message body and what both held before this turn, so
// a test reads from the reply what a later run of the same thread kept.
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const request = process.env.MOSAIC_REQUEST ?? "";
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
const projects = join(process.env.HOME, ".claude", "projects");
const transcripts = existsSync(projects) ? readdirSync(projects).sort() : null;
// A turn longer than the test's idle time.
if (body === "slow") Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
appendFileSync(historyFile, `${body}\n`);
if (transcripts) writeFileSync(join(projects, `turn-${before.length + 1}.jsonl`), `${JSON.stringify(body)}\n`);
process.stdout.write(`THREAD ${JSON.stringify({ body, before, transcripts })}\n`);
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
+369
View File
@@ -0,0 +1,369 @@
// Thread sessions (row 121, #1615): the launcher starts a thread instance's
// session when its thread has a pending message or was opened, stops it once
// the thread is idle, and gives every run of a thread the thread's own store.
// Real runners in the bwrap sandbox; a stub adapter keeps a history in the
// store and answers with what it found there.
//
// The host is in process: startBroker on bootConfig's config, with the ops
// the broker child forwards (packages/bus/src/process.mjs). The real host
// proves a human CLI process for every human verb, which a test run under an
// agent can't, so the test's human capability comes from bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { Client } from "../../bus/src/client.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fixture, tmp } from "./helpers.mjs";
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 2, idle = 1 } = {}) {
const root = tmp(t, "mosaic-thread-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return doc;
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
mkdirSync(f.dataRoot, { mode: 0o700 });
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. close() runs the launcher's
// close hook, then closes the broker, as the real host does. `ops` replaces
// launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
adapters: { pi: adapter, "claude-code": adapter },
sessionDefaults: { pollInterval: 100 },
versions: { pi: "0.85.1", claude: null },
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const threadRuns = (f, thread) => readSessions(f.dataRoot).filter((s) => s.thread === thread);
// Send to a thread and wait for the assistant's RESULT reply in that thread.
async function ask(call, thread, body) {
const sent = call("thread.send", { thread, body });
const reply = await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(reply.from_role, "assistant");
assert.equal(reply.to_role, "human");
return { sent, reply, saw: JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]) };
}
// The run stops on its own once the thread is idle: no session, no holder.
async function idleStop(f, host, thread, run) {
await until(() => launchLog(f).find((e) => e.event === "end" && e.run === run));
assert.ok(launchLog(f).find((e) => e.event === "thread-idle" && e.run === run && e.thread === thread));
assert.deepEqual(threadRuns(f, thread), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === thread).holder, null);
}
test("REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it", { skip, timeout: 90000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const B = host.call("thread.create", { project, name: "beta" }).id;
// Nothing runs for a thread with nothing pending.
await host.launcher.tick();
assert.deepEqual(readSessions(f.dataRoot), []);
const first = await ask(host.call, A, "first for A");
assert.deepEqual(first.saw, { body: "first for A", before: [], transcripts: [] });
const [run1] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).map((e) => e.run);
assert.ok(run1);
const store = join(f.dataRoot, "threads", "acme", A);
assert.equal(readFileSync(join(store, "session", "history"), "utf8"), "first for A\n");
assert.deepEqual(readdirSync(join(store, "claude-projects")), ["turn-1.jsonl"]);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme", run1, "session")), false, "a thread run keeps no per-run session directory");
await idleStop(f, host, A, run1);
// A second run of the same thread finds the first run's history.
const second = await ask(host.call, A, "second for A");
assert.deepEqual(second.saw, { body: "second for A", before: ["first for A"], transcripts: ["turn-1.jsonl"] });
const run2 = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).at(-1).run;
assert.notEqual(run2, run1);
await idleStop(f, host, A, run2);
// A restart: a new host and launcher on the same data root.
await host.close();
host = await start(t, prepared);
const third = await ask(host.call, A, "third for A");
assert.deepEqual(third.saw, { body: "third for A", before: ["first for A", "second for A"], transcripts: ["turn-1.jsonl", "turn-2.jsonl"] });
// Thread B has its own store, empty, and A's messages aren't in it.
const other = await ask(host.call, B, "for B");
assert.deepEqual(other.saw, { body: "for B", before: [], transcripts: [] });
assert.ok(!host.call("thread.messages", { thread: B }).some((m) => /for A/.test(m.body)));
assert.ok(!host.call("thread.messages", { thread: A }).some((m) => /for B/.test(m.body)));
// Opening a thread starts its session with nothing pending; it idles out too.
for (const r of readSessions(f.dataRoot)) await idleStop(f, host, r.thread, r.run);
host.call("thread.open", { thread: B });
const opened = await until(() => threadRuns(f, B)[0]);
await idleStop(f, host, B, opened.run);
});
test("REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t, { sonnet: 1 });
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const B = host.call("thread.create", { project, name: "beta" }).id;
const toA = host.call("thread.send", { thread: A, body: "for A" });
const toB = host.call("thread.send", { thread: B, body: "for B" });
const refused = await until(() => launchLog(f).find((e) => e.event === "refused" && e.thread === B));
assert.equal(refused.code, "capacity-full");
assert.equal(refused.instance, "assistant");
const replyB = await until(() => host.call("thread.messages", { thread: B }).find((m) => m.in_reply_to === toB.id), 30000);
assert.ok(host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === toA.id));
// B started only after A's run ended, and its refusal was logged once.
const entries = launchLog(f);
const endA = entries.findIndex((e) => e.event === "end" && e.run === entries.find((x) => x.event === "launch" && x.thread === A).run);
const launchB = entries.findIndex((e) => e.event === "launch" && e.thread === B);
assert.ok(endA >= 0 && launchB > endA, JSON.stringify(entries.map((e) => [e.event, e.thread, e.code])));
assert.equal(entries.filter((e) => e.event === "refused" && e.thread === B).length, 1);
assert.match(replyB.body, /THREAD/);
});
test("REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
// The stub's "slow" turn takes 3 s and the idle time is 1 s. The message is
// unread for the whole turn.
const sent = host.call("thread.send", { thread: A, body: "slow" });
const reply = () => host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT");
await until(() => reply() || launchLog(f).find((e) => e.event === "thread-idle"), 30000);
assert.ok(reply(), JSON.stringify(launchLog(f)));
await idleStop(f, host, A, launchLog(f).find((e) => e.event === "launch" && e.thread === A).run);
});
// Dewey's F3 (28591): the tick decides from a snapshot. A message sent after it
// keeps the session, which answers it, instead of going to a run being stopped.
test("REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let race = null;
// Ticks only when the test calls tick(). The snapshot is taken before the race's send.
const host = await start(t, prepared, {
tickMs: 3600000,
ops: (runtime) => ({
threadsState: (m) => {
const snapshot = runtime.threadsState(m.business);
race?.();
race = null;
return snapshot;
},
}),
});
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const reply = (sent) => host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT");
const first = host.call("thread.send", { thread: A, body: "first for A" });
await host.launcher.tick();
await until(() => reply(first));
const [launch] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A);
// Past the idle time, so the snapshot says stop.
await new Promise((r) => setTimeout(r, 1200));
let late;
race = () => (late = host.call("thread.send", { thread: A, body: "late for A" }));
await host.launcher.tick();
assert.ok(late, "the race ran");
assert.equal(launchLog(f).find((e) => e.event === "thread-idle"), undefined, JSON.stringify(launchLog(f)));
await until(() => reply(late));
assert.deepEqual(launchLog(f).filter((e) => e.event === "launch").map((e) => e.run), [launch.run]);
// With nothing waiting, the next tick past the idle time stops it.
await new Promise((r) => setTimeout(r, 1200));
await host.launcher.tick();
await idleStop(f, host, A, launch.run);
});
// A thread held by a run the launcher has no session for, as after a restart
// before recovery. The hold is the bus's, so the launcher starts nothing.
test("REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const held = host.runtime.bindLaunch({ business: "acme", role: "assistant", run: "held-by-hand", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid), thread: A });
host.runtime.broker.request(held.cap, { verb: "role.claim" });
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, "held-by-hand");
host.call("thread.send", { thread: A, body: "for A" });
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(launchLog(f), []);
});
// A run that ends before it takes the message it was started for. The next
// start waits for a new message or open, so a run that dies at once doesn't
// start again on every tick.
test("REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
// Ticks only when the test calls tick().
const host = await start(t, prepared, { tickMs: 3600000 });
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const held = host.runtime.bindLaunch({ business: "acme", role: "assistant", run: "held-by-hand", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid), thread: A });
host.call("thread.send", { thread: A, body: "for A" });
await host.launcher.tick();
// tick() returns once the run is bound and its capability is queued on its
// stdin. This claim is synchronous, so it comes before the run's own.
host.runtime.broker.request(held.cap, { verb: "role.claim" });
const [launch] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A);
const end = await until(() => launchLog(f).find((e) => e.event === "end" && e.run === launch.run));
assert.equal(end.exitCode, 21, "the run's claim was refused");
host.runtime.broker.request(held.cap, { verb: "role.release" });
const state = host.runtime.threadsState("acme").find((x) => x.thread === A);
assert.deepEqual([state.holder, state.pending], [null, 1]);
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(launchLog(f).filter((e) => e.event === "launch").length, 1);
assert.deepEqual(readSessions(f.dataRoot), []);
});
// A thread id that can't be a path segment, made by hand in the store before
// the host starts, and a thread of a non-thread instance. Neither launches.
test("REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const store = new Store(f.dataRoot);
const at = () => new Date().toISOString();
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
for (const [id, instance] of [["../evil", "assistant"], ["coder-thread", "coder"]]) {
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", id, at(), "acme", "p-hand", instance, "jason", id);
store.run("INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)", at(), id, "jason");
}
store.close();
const host = await start(t, prepared);
const refusals = await until(() => {
const r = launchLog(f).filter((e) => e.event === "refused");
return r.length === 2 && r;
});
assert.deepEqual(refusals.map((e) => [e.thread, e.instance, e.code]).sort(), [["../evil", "assistant", "thread-store-refused"], ["coder-thread", "coder", "thread-not-allowed"]]);
assert.match(refusals.find((e) => e.thread === "../evil").why, /thread "..\/evil" isn't an id/);
// More ticks log nothing more and start nothing.
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(launchLog(f).length, 2);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(join(f.dataRoot, "threads")), false);
assert.equal(existsSync(join(f.dataRoot, "evil")), false);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme")), false, "no run directory");
assert.equal(existsSync(join(f.dataRoot, "workspaces")), false, "no workspace");
});
test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const chief = await host.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.host.path, cap: chief.cap }).call("role.claim");
assert.deepEqual(await host.launcher.fromSession(chief.cap, "assistant"), { ok: false, error: "thread-required" });
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
+23
View File
@@ -253,6 +253,29 @@ The sandbox doesn't confine the network, abstract Unix sockets, or the
user: a session runs as the host's UID. `packages/harness/README.md`
("Limits") says what that leaves open.
### Thread stores (row 121, #1615)
A thread's harness session outlives each of the thread's runs, so the next
run for the thread resumes it (REQ-THREAD-2). `threadStore(dataRoot,
business, thread)` returns its paths under the data root:
`threads/<business>/<thread>/` (0700) holds `session/`, which the launcher
uses as the run's session directory (`MOSAIC_SESSION_DIR`), and
`claude-projects/`, Claude Code's transcripts.
It checks both segments before it joins them. Each must match the business
vocabulary's id pattern and hold no `..`, so neither has a `/` or a leading
`.`. Then it makes each directory from `threads/` down, and refuses one that
is a symlink, isn't a directory, isn't the user's, or has group or other
bits, as the bus store checks its own directory. A refusal is a `SeatError`;
the launcher turns it into `thread-store-refused`.
`binds()` takes the store and adds two read-write binds: `session/` at its
own path, and `claude-projects/` at the run's `home/.claude/projects`, a
mount point `prepareRun` makes. A run gets only its own thread's store.
`Registry.running(business, instance, thread)` keys a session by its thread
too, `null` for a session that holds none, so one thread instance runs once
per thread.
### The project clones (#1585)
An instance's workspace holds one git clone per project in the business
+13 -3
View File
@@ -95,8 +95,9 @@ function systemBinds(node = process.execPath) {
// the harness ("pi" or "claude-code"), the broker socket, the launch socket
// (null for a role that may not launch), the git socket (null for a role
// that neither pushes nor reviews), the Claude Code binary
// (MOSAIC_CLAUDE_BIN, or null) and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harness, brokerSocket, launchSocket = null, gitSocket = null, claudeBin = null, node = process.execPath, readOnly = [] }) {
// (MOSAIC_CLAUDE_BIN, or null), the thread's session store (session.mjs
// threadStore(), or null) and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harness, brokerSocket, launchSocket = null, gitSocket = null, claudeBin = null, threadStore = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
@@ -116,6 +117,13 @@ export function binds({ runDir, workspace, adapter, harness, brokerSocket, launc
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
{ kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: "a private /tmp, on disk in the run directory" },
);
// Row 121: only this thread's store, and only its two directories.
if (threadStore) {
out.push(
{ kind: "rw", src: threadStore.session, why: "the thread's session directory, kept across its runs (MOSAIC_SESSION_DIR)" },
{ kind: "rw", src: threadStore.projects, dest: join(runDir, "home", ".claude", "projects"), why: "the thread's Claude Code transcripts, kept across its runs" },
);
}
return out;
}
@@ -132,8 +140,10 @@ function argsOf(list) {
}
// The run's home/ and tmp/, made before the spawn: binds need their sources.
export function prepareRun(runDir) {
// A thread run also needs the mount point for its transcripts.
export function prepareRun(runDir, { threadStore = null } = {}) {
for (const d of ["home", "tmp"]) mkdirSync(join(runDir, d), { recursive: true, mode: 0o700 });
if (threadStore) mkdirSync(join(runDir, "home", ".claude", "projects"), { recursive: true, mode: 0o700 });
}
// The git identity a session commits under (#1598): the role instance's
+38 -6
View File
@@ -20,17 +20,21 @@
// every launch, refusal and end
// workspaces/<business>/<instance>/ the instance's workspace (0700), kept
// across launches
// threads/<business>/<thread>/ a thread's session store (0700), kept
// across the thread's runs (row 121):
// session/ and claude-projects/
// bus-host/sessions.json the running sessions (0600), written
// only by the host; `mosaic stop` reads it
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { constants } from "node:os";
import { dirname, join } from "node:path";
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs";
import { SeatError } from "./seat.mjs";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
export { REPO };
export const RUNNER = join(REPO, "packages", "harness", "src", "runner.mjs");
@@ -48,6 +52,32 @@ export const runsDir = (dataRoot, business) => join(dataRoot, "launches", busine
export const launchLogFile = (dataRoot, business) => join(dataRoot, "launches", `${business}.jsonl`);
export const workspaceDir = (dataRoot, business, instance) => join(dataRoot, "workspaces", business, instance);
// Row 121 (#1615): a thread's session store, which outlives each of the
// thread's runs so the next run resumes the harness session (REQ-THREAD-2).
// Both segments are checked before the join: an id, no "..", so no "/" and
// no leading ".". Then every directory from threads/ down must be ours, a
// real directory and private, as store.mjs safe() checks the bus. session/
// is the run's sessionDir; claude-projects/ is bound at the run's
// home/.claude/projects (sandbox.mjs binds()). Throws SeatError.
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
export function threadStore(dataRoot, business, thread) {
for (const [what, x] of [["business", business], ["thread", thread]]) {
if (!segment(x)) throw new SeatError(`thread store: ${what} ${JSON.stringify(x)} isn't an id`, 2);
}
const dir = join(dataRoot, "threads", business, thread);
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
for (const d of [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects]) {
try {
mkdirSync(d, { mode: 0o700 });
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const st = lstatSync(d);
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw new SeatError(`thread store: ${d} is unsafe`, 2);
}
return out;
}
// The session's environment is an allowlist: nothing else from the host's
// environment passes, so a founder credential variable (GITEA_TOKEN,
// GH_TOKEN, SSH_AUTH_SOCK, ...) never reaches a session (REQ-CRED-2).
@@ -110,10 +140,10 @@ export function appendLaunchLog(dataRoot, business, entry) {
// the host writes once the broker has bound the launch; stdout and stderr go
// to runner.log. paths: what sandbox.mjs's binds() takes besides the run
// directory (workspace, adapter, harness, brokerSocket, launchSocket,
// claudeBin; readOnly for tests). business and role (the instance) give the
// session its git identity. `bwrap` is for the tests.
// claudeBin, threadStore; readOnly for tests). business and role (the
// instance) give the session its git identity. `bwrap` is for the tests.
export function spawnSession({ runDir, env, paths, business, role, node = process.execPath, bwrap }) {
prepareRun(runDir);
prepareRun(runDir, { threadStore: paths.threadStore ?? null });
const argv = sandboxArgv(binds({ ...paths, runDir, node }), runDir, [node, RUNNER, runDir], { business, role, bwrap });
const out = openSync(join(runDir, "runner.log"), "a", 0o600);
try {
@@ -181,8 +211,10 @@ export class Registry {
count(business, provider, model = undefined) {
return this.#sessions.filter((s) => s.business === business && s.provider === provider && (model === undefined || s.model === model)).length;
}
running(business, instance) {
return this.#sessions.find((s) => s.business === business && s.instance === instance) ?? null;
// A thread instance runs once per thread (row 121), so `thread` is part of
// the key; null is a session that holds no thread.
running(business, instance, thread = null) {
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
}
#write() {
const file = sessionsFile(this.#dataRoot);
+24 -1
View File
@@ -9,7 +9,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
@@ -70,6 +70,29 @@ test("every bind says why; the bundle and session file stay read-only over the r
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules");
});
// Row 121 (#1615): a thread run gets only its own thread's two directories,
// writable, and a home/.claude/projects mount point made before the sandbox starts.
test("REQ-THREAD-2 a thread store binds its session directory and Claude transcripts, and nothing more", (t) => {
const root = scratch(t);
const runDir = join(root, "run");
const store = { dir: join(root, "threads", "acme", "th"), session: join(root, "threads", "acme", "th", "session"), projects: join(root, "threads", "acme", "th", "claude-projects") };
const args = { runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") };
const list = binds({ ...args, threadStore: store });
const plain = binds(args);
assert.deepEqual(list.slice(0, plain.length), plain, "the other binds are unchanged");
assert.deepEqual(list.slice(plain.length).map(({ kind, src, dest }) => ({ kind, src, dest })), [
{ kind: "rw", src: store.session, dest: undefined },
{ kind: "rw", src: store.projects, dest: join(runDir, "home", ".claude", "projects") },
]);
assert.ok(list.every((b) => b.why));
assert.equal(list.findIndex((b) => b.src === store.dir || b.src === join(root, "threads")), -1, "not the store's parent, not other threads");
prepareRun(runDir);
assert.equal(existsSync(join(runDir, "home", ".claude")), false);
prepareRun(runDir, { threadStore: store });
assert.equal(statSync(join(runDir, "home", ".claude", "projects")).mode & 0o777, 0o700);
});
// Row 93 (#1587): the git socket is bound read-only, before the workspace, when the launcher passes one.
test("the host's git socket is bound only when given, read-only and before the writable binds", (t) => {
const root = scratch(t);
+43 -1
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
@@ -30,6 +30,7 @@ import {
spawnSession,
sessionExit,
stopSession,
threadStore,
writeSessionFile,
} from "../src/session.mjs";
import { BWRAP, sandboxProblem } from "../src/sandbox.mjs";
@@ -132,6 +133,47 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
});
// Row 121 (#1615): a thread instance runs once per thread, so the registry
// keys a thread run by its thread too.
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
const r = new Registry(scratch(t));
const base = { business: "acme", instance: "assistant", provider: "zai", model: "m", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) };
r.add({ ...base, run: "r1", thread: "th-a" });
assert.equal(r.running("acme", "assistant", "th-a").run, "r1");
assert.equal(r.running("acme", "assistant", "th-b"), null);
assert.equal(r.running("acme", "assistant"), null, "a thread run isn't the instance's non-thread run");
r.add({ ...base, instance: "coder", run: "r2" });
assert.equal(r.running("acme", "coder").run, "r2");
assert.equal(r.running("acme", "coder", "th-a"), null);
});
// Row 121 (#1615, sage-0308 change 3): <business> and <thread> are checked
// against the id pattern before the path join, then every directory is lstat-checked.
test("REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory", (t) => {
const root = scratch(t);
for (const [business, thread] of [["acme", ".."], ["acme", "a/b"], ["acme", ".x"], ["acme", "a..b"], ["..", "th"], ["acme", ""], ["acme", "Th"], ["acme", 7], [undefined, "th"]]) {
assert.throws(() => threadStore(root, business, thread), (e) => e instanceof SeatError && /^thread store: (business|thread) .* isn't an id$/.test(e.message), `${business}/${thread}`);
}
assert.equal(existsSync(join(root, "threads")), false, "a refused segment makes nothing");
const s = threadStore(root, "acme", "th-1");
assert.deepEqual(s, { dir: join(root, "threads", "acme", "th-1"), session: join(root, "threads", "acme", "th-1", "session"), projects: join(root, "threads", "acme", "th-1", "claude-projects") });
for (const d of [join(root, "threads"), join(root, "threads", "acme"), s.dir, s.session, s.projects]) assert.equal(mode(d), 0o700, d);
writeFileSync(join(s.session, "history"), "kept\n");
assert.deepEqual(threadStore(root, "acme", "th-1"), s, "a second call reuses the store");
assert.equal(readFileSync(join(s.session, "history"), "utf8"), "kept\n");
chmodSync(s.session, 0o755);
assert.throws(() => threadStore(root, "acme", "th-1"), (e) => e instanceof SeatError && /is unsafe$/.test(e.message));
chmodSync(s.session, 0o700);
const elsewhere = join(root, "elsewhere");
mkdirSync(elsewhere, { mode: 0o700 });
symlinkSync(elsewhere, join(root, "threads", "acme", "th-2"));
assert.throws(() => threadStore(root, "acme", "th-2"), (e) => e instanceof SeatError && /th-2 is unsafe$/.test(e.message));
assert.equal(existsSync(join(elsewhere, "session")), false, "nothing is made through the link");
assert.ok(lstatSync(join(root, "threads", "acme", "th-2")).isSymbolicLink());
});
test("stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner", async (t) => {
const root = scratch(t);
const r = new Registry(root);