Candidate manifest sha256 710d50866e77055b7231a53e3601bd2bab7686b722160f81ddda030875f67e9b, 30 files. Targeted gate green, 77 of 77 mutants killed, repro DIGESTS_OK. Co-Authored-By: Claude Opus 5.5 <[email protected]>
3257 lines
182 KiB
Diff
3257 lines
182 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index 36965636c..26220494f 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -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.
|
|
|
|
diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql
|
|
index 337ae80b9..0c636369d 100644
|
|
--- a/packages/bus/schema.sql
|
|
+++ b/packages/bus/schema.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index d131f2a9d..49c51cd8e 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -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) => {
|
|
diff --git a/packages/bus/src/migrate-schema.mjs b/packages/bus/src/migrate-schema.mjs
|
|
index b6b3893f4..89c16a23c 100644
|
|
--- a/packages/bus/src/migrate-schema.mjs
|
|
+++ b/packages/bus/src/migrate-schema.mjs
|
|
@@ -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`);
|
|
diff --git a/packages/bus/src/migrations/3d-3e.sql b/packages/bus/src/migrations/3d-3e.sql
|
|
new file mode 100644
|
|
index 000000000..4016d3e7c
|
|
--- /dev/null
|
|
+++ b/packages/bus/src/migrations/3d-3e.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs
|
|
index 0cb3f3e05..d3e0131a4 100644
|
|
--- a/packages/bus/src/process.mjs
|
|
+++ b/packages/bus/src/process.mjs
|
|
@@ -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
|
|
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
|
index c28716de4..8e59d36ab 100644
|
|
--- a/packages/bus/src/runtime.mjs
|
|
+++ b/packages/bus/src/runtime.mjs
|
|
@@ -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,
|
|
diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs
|
|
index 99e1f1ac0..81e01cca7 100644
|
|
--- a/packages/bus/src/store.mjs
|
|
+++ b/packages/bus/src/store.mjs
|
|
@@ -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 =
|
|
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
|
|
index a224c7546..67b962f89 100644
|
|
--- a/packages/bus/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/bus/tests/chief-of-staff.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs
|
|
index d2ac6928a..555da864c 100644
|
|
--- a/packages/bus/tests/comment-migration.test.mjs
|
|
+++ b/packages/bus/tests/comment-migration.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/bus/tests/decision-route.test.mjs b/packages/bus/tests/decision-route.test.mjs
|
|
index 633521ff7..6de29df6c 100644
|
|
--- a/packages/bus/tests/decision-route.test.mjs
|
|
+++ b/packages/bus/tests/decision-route.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/bus/tests/end-launch.test.mjs b/packages/bus/tests/end-launch.test.mjs
|
|
index 68720d711..cdd206a4c 100644
|
|
--- a/packages/bus/tests/end-launch.test.mjs
|
|
+++ b/packages/bus/tests/end-launch.test.mjs
|
|
@@ -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.
|
|
diff --git a/packages/bus/tests/fixtures/schema-3d.sql b/packages/bus/tests/fixtures/schema-3d.sql
|
|
new file mode 100644
|
|
index 000000000..337ae80b9
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/fixtures/schema-3d.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/tests/messaging.test.mjs b/packages/bus/tests/messaging.test.mjs
|
|
index a1d6f9e53..883dccf66 100644
|
|
--- a/packages/bus/tests/messaging.test.mjs
|
|
+++ b/packages/bus/tests/messaging.test.mjs
|
|
@@ -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(), []);
|
|
});
|
|
|
|
diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs
|
|
index 5ef0c87dc..ef940a297 100644
|
|
--- a/packages/bus/tests/schema.test.mjs
|
|
+++ b/packages/bus/tests/schema.test.mjs
|
|
@@ -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);
|
|
});
|
|
diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs
|
|
new file mode 100644
|
|
index 000000000..47ebfb262
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/thread-migration.test.mjs
|
|
@@ -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'));
|
|
+});
|
|
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
|
new file mode 100644
|
|
index 000000000..e29c0e62d
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/threads.test.mjs
|
|
@@ -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));
|
|
+});
|
|
diff --git a/packages/business/README.md b/packages/business/README.md
|
|
index f0d2d7097..e5ca85610 100644
|
|
--- a/packages/business/README.md
|
|
+++ b/packages/business/README.md
|
|
@@ -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
|
|
diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs
|
|
index 4fa492d4a..21a7f5a65 100644
|
|
--- a/packages/business/src/business.mjs
|
|
+++ b/packages/business/src/business.mjs
|
|
@@ -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
|
|
diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs
|
|
index 65f696897..bed5b14d3 100644
|
|
--- a/packages/business/tests/business.test.mjs
|
|
+++ b/packages/business/tests/business.test.mjs
|
|
@@ -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");
|
|
diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs
|
|
index c92c4cc6e..fcddaeba4 100644
|
|
--- a/packages/business/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/business/tests/chief-of-staff.test.mjs
|
|
@@ -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", () => {
|
|
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
|
index 90836150c..da6877607 100644
|
|
--- a/packages/cli/README.md
|
|
+++ b/packages/cli/README.md
|
|
@@ -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,
|
|
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
|
index 40f85ad42..cd11b7525 100644
|
|
--- a/packages/cli/src/launcher.mjs
|
|
+++ b/packages/cli/src/launcher.mjs
|
|
@@ -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 };
|
|
}
|
|
diff --git a/packages/cli/tests/fixtures/thread-adapter.mjs b/packages/cli/tests/fixtures/thread-adapter.mjs
|
|
new file mode 100644
|
|
index 000000000..ae2d98efc
|
|
--- /dev/null
|
|
+++ b/packages/cli/tests/fixtures/thread-adapter.mjs
|
|
@@ -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");
|
|
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
|
new file mode 100644
|
|
index 000000000..76a1673c4
|
|
--- /dev/null
|
|
+++ b/packages/cli/tests/thread-launch.test.mjs
|
|
@@ -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"]]);
|
|
+});
|
|
diff --git a/packages/seat/README.md b/packages/seat/README.md
|
|
index a94ff8ac4..eac578335 100644
|
|
--- a/packages/seat/README.md
|
|
+++ b/packages/seat/README.md
|
|
@@ -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
|
|
diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs
|
|
index fdf41bbef..bb992ad94 100644
|
|
--- a/packages/seat/src/sandbox.mjs
|
|
+++ b/packages/seat/src/sandbox.mjs
|
|
@@ -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
|
|
diff --git a/packages/seat/src/session.mjs b/packages/seat/src/session.mjs
|
|
index 19c4fc53d..7ffb89c04 100644
|
|
--- a/packages/seat/src/session.mjs
|
|
+++ b/packages/seat/src/session.mjs
|
|
@@ -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);
|
|
diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs
|
|
index 77168d405..7cbb25327 100644
|
|
--- a/packages/seat/tests/sandbox.test.mjs
|
|
+++ b/packages/seat/tests/sandbox.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/seat/tests/session.test.mjs b/packages/seat/tests/session.test.mjs
|
|
index c9babcd6a..a31297594 100644
|
|
--- a/packages/seat/tests/session.test.mjs
|
|
+++ b/packages/seat/tests/session.test.mjs
|
|
@@ -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);
|