Compare commits

...
7 changed files with 100 additions and 11 deletions

No files matched your search

+1
View File
@@ -6115,3 +6115,4 @@ runs were joined. Queue remains with the lead after the linked-worktree refusal.
Local commit only; no push or live instance/credential-file access.
2026-11-04T17:21:41Z | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 lifecycle Task 6b before/after | Before: origin/refactor 103168614 includes Task 6a intake/staff tools and Task 5a create recovery; Task 4 source 76207fe48 cherry-picked as e41ec8159d23d52963d869abf22fc9d430831f44 with both append-only log sides retained. No checked task/review advancement or durable role continuation recovery existed. After: PM-only submit_prd and advance_request map to exact PRD submission and evidence-derived planning → implementation → review → delivery → complete transitions (changes return to implementation); task-create/assignment, latest project push/review/verdict and closure evidence are rechecked under the request revision and current snapshot. Eligible PM/CTO intake_status path reads expose only selected committed draft documents. Durable continuation intents are keyed by request/revision/target/step and composed atomically with outgoing messages; immutable attempt, manifest and exact launch-refusal receipts deduplicate retries. Message reads never acknowledge progress; validated lifecycle transitions and the chief's delivery effect in the originating assistant session do. Startup/event recovery plus a single-flight 30-second pass bounded to 64 requests catches missed wakeups. Successors receive the same unfinished key, with existing routed/effect receipts rechecked before resending. The launcher starts only the configured chief through its existing launchPm path; workers use that chief's real launch/end authority. Old product manifests drain their current turn, then the chief relaunches with request.admission and real ensureCheckouts. Exact refusals appear as RequestState.launch_refusal for intake-panel integration; no Console panel is present in this base. No synthetic capability, PM launch grant, hire through the empty chief hireable list, roles/*.json change, extra scheduler service, live-instance action, configuration/credential mount or new worker import was added. Six role duty documents updated. Migration 3q-3r is necessary solely to add the checked advanced lifecycle event to the closed request_events kind list; it preserves rows, sequence and all append-only guards, with historical fixture/version pins updated. Corrections: migration digest/pins and one bundle tool-list assertion were updated; notification scheduling was narrowed so reads do not cause unsolicited bootstrap retries; an accidental repository-root npm test invocation reported no test script and was corrected to packages/harness. One broad bus run hit the pre-existing #656 FIFO child niceness 16 vs 19 assertion (also recorded by Task 4); that test passed 21/21 in isolation and the final full bus rerun passed without a source change to it. Final GREEN, zero skips: new 43/43, focused compatibility 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36; extra sandbox import closure 11/11, git diff --check clean. Logs: /mnt/storage/scratch/tmp/task6b-final-new.log, task6b-final-focused.log, task6b-final-harness.log, task6b-final-bus.log, task6b-final-business.log, task6b-final-launcher.log and task6b-final-sandbox.log in the same directory; earlier failure/isolated logs retained. All test invocations used fresh TMPDIR directories from /mnt/storage/scratch/tmp/XXXXXX. Queue command refused this linked worktree, so no queue movement was attempted in the canonical checkout. One local implementation commit only; no push, merge, release, deployment or live-instance changes.
2026-11-04T17:57:52Z | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase before/after | Before: local 347791b55 on the superseded Task 4 cherry-pick e41ec8159; origin/refactor is b5a58b48b4c254f166135c24e22bd63ce4b5e0b9 with Task 4 c098fa022, #772 793f3b157, Task 3 and Task 5b. After: replay only 347791b55 on the explicitly requested new branch t3/lifecycle-task-6b-r2; e41ec8159 is dropped. Shipped Task 3 migration 3q-3r and all seventeen existing migration files are byte-identical to the base. New 3r-3s rebuild preserves all fifteen shipped event kinds (including question.asked, question.answered, attach.selected and every Task 4 PRD/mode kind) and adds advanced, for sixteen; schema.sql matches, SCHEMA_VERSION=3s, SCHEMA_SHA256=fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03. The migration test carries rows of every prior kind, preserves a sqlite_sequence high-water mark, index and every request/effect guard, then appends advanced at the next sequence. Current-version, offline-chain and synthetic future-step test pins updated through 3s (future fixture 3t). Conflicts resolved by keeping both behavior sets: broker retains Console owner/verb guards, request question/answer/attach/cancel handlers, Task 5b builtin binding and scoped requirements/git admission, plus 6b advance and continuation services; lifecycle exports the union of Task 3 and 6b operations; runtime retains Task 5b builtin binding and #772 pending-product readiness while adding 6b recovery; harness keeps Task 5b project/snapshot/op schemas and catalog prose while adding 6b tools, with duplicate schema properties removed; six role duty documents merged cleanly and retained; append-only logs keep both sides. Integration corrections: unanswered owner questions suspend new continuation dispatch, answer evidence resumes it, and planning continuations carry the stable create op required by Task 5b. A real Task 5b adapter test creates/assigns the task with that op, advances it and keeps cross-project git refused. First full bus run 1554/1555 found a Task 3 global-zero-effects assertion; it now asserts attach preserves the exact preexisting continuation receipts. Another bus run and isolated #656 control reproduced the existing nice 16 versus 19 race: the installed Node scheduling rule changes startup priority. The owned FIFO child now sets priority after fixture construction immediately before the measured operation; all priority/nonblocking assertions remain intact, no daemon or live instance changed. FIFO 21/21 and final full bus 1555/1555 pass. First WebUI run 537/540, two failures and one skip: missing local esbuild 0.28.2 and one browser refresh timeout; npm ci --ignore-scripts installed only the locked WebUI dependencies, the browser file passes 2/2 in isolation, and whole WebUI passes 540/540 with MOSAIC_REQUIRE_EDITOR_BUILD=1 and test-concurrency=4. An accidental root npm test invocation reported no script and ran no tests; corrected to the bus package. Final GREEN, zero skips/cancellations: new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540. Every test invocation used its own mktemp -d /mnt/storage/scratch/tmp/XXXXXX TMPDIR. Logs /mnt/storage/scratch/tmp/task6b-r2-new-final.log, task6b-r2-bus-final.log, task6b-r2-harness.log, task6b-r2-business.log, task6b-r2-launch.log, task6b-r2-webui-final.log; complete per-run counts in task6b-r2-runs.json and schema evidence in task6b-r2-schema-proof.json in the same directory. No unresolved conflict, role JSON authority change, shipped migration edit, credential/config mount, push, merge, deployment or live-instance action. Queue command again refused this linked worktree; canonical coordination is unchanged. Local rebase only, with no attribution trailers.
2026-11-04T18:34:22Z | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question before/after | Before: base 647626bdd exposes request.question on the bus but no PM harness tool. After: PM with message.send gets intake_question in both harnesses, forwarding only request, revision, op, text and optional bounded choices; the existing broker enforces exact shape, distinct choice keys, authority and replay. A stable caller-supplied op and exact retry use the broker journal, while bus refusals remain errors and lost/unknown outcomes are reported as unknown with no automatic retry. PM duty prose describes status reconciliation and that answers do not approve a PRD. Red evidence: request-tools red-real.log 19/25, with missing tool and unknown-outcome failures; earlier fixture corrections are retained in the logs. Final gates: harness 234/234, bus 1556/1556, business 307/307, all zero failures/skips/cancellations; tests include both harness manifests, unauthorized roles, invalid input, refusal propagation, unknown outcome, stable-op replay and owner Console answer through the real bus. Logs: /mnt/storage/scratch/tmp/W6Jjos/; each invocation used a fresh TMPDIR from /mnt/storage/scratch/tmp/XXXXXX and pipefail. No bus, role JSON, contract or migration change; local commit only, no push. Canonical queue next t3 returned nothing; coordination stays with the controller.
+1
View File
@@ -2013,3 +2013,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b | #797 / Gitea #1839 Task 6b | Typed PRD/advance and scoped draft reads; durable continuations, evidence acknowledgements, chief launch refusal/retry and manifest drain/relaunch; migration 3q-3r. New 43/43, focused 56/56, harness 227/227, bus 1512/1512, business 305/305, launcher 36/36, extra sandbox 11/11, zero skips; /mnt/storage/scratch/tmp/task6b-final-*.log. Task 4 cherry-pick e41ec8159 from 76207fe48; one local commit only, no push/live changes, queue left to canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r2 | #797 / Gitea #1839 Task 6b rebase onto b5a58b48b | Dropped e41ec8159; merged Task 3 controls/questions, Task 5b scope/op admission and 6b continuations. New migration 3r-3s retains all fifteen shipped kinds plus advanced; all shipped migrations unchanged. Added question/op integration checks and fixed owned FIFO priority setup timing without weakening assertions. Final new 45/45, bus 1555/1555, harness 228/228, business 307/307, launch 8/8, whole WebUI 540/540, zero skips; per-run counts/log paths /mnt/storage/scratch/tmp/task6b-r2-runs.json. Both append-only logs retained; local rebase only, no attribution trailers or push/live changes; queue remains with canonical coordinator after linked-worktree refusal.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-task-6b-r3 | #797 / Gitea #1839 Task 6b rebase onto 68bef84fa | Kept both append-only log sides, the Task 4 snapshot-counter behavior and the full 6b continuation changes; 3r-3s migration remains byte-identical to 65588327d. Gates, zero skips: bus 1556/1556, harness 228/228, business 307/307, webui 554/554; logs /mnt/storage/scratch/tmp/nj4n7n/gate-*.log. Local commit only, no push.
2026-11-04 | Codex implementation sub-agent, t3/lifecycle-intake-question | #797 / Gitea #1839 PM intake question tool | PM-only typed request.question, strict bounded broker arguments, stable-op replay, refusal and unknown-outcome propagation, owner-answer integration and PM duty prose; red-first; harness 234/234, bus 1556/1556, business 307/307, zero skips; logs /mnt/storage/scratch/tmp/W6Jjos/. Local commit only; no push, bus, role JSON, contract or migration changes. Canonical queue next t3 returned nothing.
+17 -6
View File
@@ -127,6 +127,16 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
}
if (resolved.definition === "pm" && held.has("message.send")) {
add("intake_open", "request.open", "message.send", "Open a reachable user request for pre-task intake, preserving its original intent. Opening an already open request returns its state.", obj({ request: REQUEST }, ["request"]));
add("intake_question", "request.question", "message.send", "Ask the owner one question during discovery, PRD review or planning. Use the current request revision and a stable op id; reuse the exact arguments and op only for a retry of the same question. An unknown outcome is not a refusal: check intake_status before retrying. Answers do not approve a PRD.", obj({
request: str("Original request id", { minLength: 1, maxLength: 160, pattern: "^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$" }),
revision: { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Current request revision" },
op: str("Stable operation id for this question; reuse only with identical arguments", { minLength: 1, maxLength: 160, pattern: "^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$" }),
text: str("Question text", { minLength: 1, maxLength: 2000, pattern: "^[^\\u0000]+$" }),
options: { type: "array", maxItems: 8, description: "Optional choices with distinct keys", items: obj({
key: str("Choice key", { minLength: 1, maxLength: 160, pattern: "^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$" }),
text: str("Choice text", { minLength: 1, maxLength: 200, pattern: "^[^\\u0000]+$" }),
}, ["key", "text"]) },
}, ["request", "revision", "op", "text"]));
add("intake_propose", "request.propose", "message.send", "Propose a project for an open request at its current revision. The broker prepares the exact resource plans and raises the user's new-project mode decision, or records a setup wait. This does not approve or bootstrap a project.", obj({
request: REQUEST,
revision: { type: "integer", minimum: 1, description: "Current request revision from intake_status or intake_open" },
@@ -404,19 +414,20 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
socket.destroy();
error ? reject(error) : resolve(value);
};
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
const unknown = (message) => new Error(tool === "intake_question" ? `outcome-unknown: ${message}` : message);
socket.setTimeout(timeout, () => end(unknown("tool call timed out")));
socket.on("connect", () => socket.write(JSON.stringify({ tool, args: { ...ARG_DEFAULTS[tool], ...args } }) + "\n"));
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
socket.on("end", () => end(new Error("tool socket closed without a reply")));
socket.on("error", (e) => end(unknown(`tool socket: ${e.code ?? e.message}`)));
socket.on("end", () => end(unknown("tool socket closed without a reply")));
socket.on("data", (b) => {
input += b.toString("utf8");
if (input.length > 4 * 1024 * 1024) return end(new Error("tool reply too large"));
if (input.length > 4 * 1024 * 1024) return end(unknown("tool reply too large"));
if (!input.includes("\n")) return;
try {
const r = JSON.parse(input);
r.ok ? end(null, r.result) : end(new Error(`refused: ${r.error}`));
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : `refused: ${r.error}`));
} catch {
end(new Error("tool reply isn't JSON"));
end(unknown("tool reply isn't JSON"));
}
});
});
+1 -1
View File
@@ -60,7 +60,7 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/);
assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_propose, advance_request, submit_prd, task_create, /);
assert.match(prompt, /Bus tools: send_message, request_staff, intake_status, intake_projects, intake_open, intake_question, intake_propose, advance_request, submit_prd, task_create, /);
assert.doesNotMatch(prompt, /Bus tools: [^.]*\blaunch\b/);
// Row 90 (#1584): PM fault 10 (#228 c91).
assert.match(prompt, /^- Only the human stops a run, with `mosaic stop`\. If you think a run should stop, raise a role\.revoke decision with blocking false/m);
+74 -3
View File
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { createServer } from 'node:net';
import { buildBundle, SOURCES } from '../src/bundle.mjs';
import { callTool, typedTools } from '../src/tools.mjs';
import * as runner from '../src/runner.mjs';
@@ -13,9 +14,10 @@ import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { createRequestStore } from '../../bus/src/request-store.mjs';
import { createRequestLifecycle } from '../../bus/src/request-lifecycle.mjs';
import { requestFixture } from '../../bus/tests/request-fixture.mjs';
const reads = ['intake_status', 'intake_projects'];
const writes = ['intake_open', 'intake_propose'];
const writes = ['intake_open', 'intake_propose', 'intake_question'];
const intake = t => t.name.startsWith('intake_');
const shipped = name => JSON.parse(readFileSync(join(REPO, 'roles', `${name}.json`), 'utf8'));
const roleDefinition = role => role === 'chief' ? 'chief-of-staff' : role;
@@ -43,7 +45,6 @@ for (const harness of ['pi', 'claude-code']) {
const tools = JSON.parse(readFileSync(b.files.tools, 'utf8'));
assert.deepEqual(tools.filter(intake).map(t => t.name).sort(), expected.toSorted());
assert.deepEqual(b.policy.typed.filter(n => n.startsWith('intake_')).sort(), expected.toSorted());
assert.ok(!tools.some(t => t.name === 'intake_question'), 'no backing request.question verb');
for (const tool of tools.filter(intake)) {
assert.equal(tool.parameters.additionalProperties, false);
assert.equal(tool.parameters.properties.request.type, 'string');
@@ -195,7 +196,6 @@ test('broker keeps wrong-role intake refusals, with eligible CTO reads', async t
await assert.rejects(x.tool('intake_open', { request: x.source.id }), /request-origin-refused/);
await assert.rejects(x.tool('intake_status', { ...args, extra: true }), /invalid-request/);
await assert.rejects(x.tool('intake_propose', { ...args, revision: 0, op: 'bad-revision', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 }), /invalid-request/);
await assert.rejects(x.tool('intake_question', args), /unknown-tool/);
});
test('pre-task staffing still requires PM message.send authority', async t => {
@@ -232,3 +232,74 @@ test('product task and catalog tools carry project and exact snapshot fields', t
assert.equal(schema.properties.requirement_snapshot.type, 'string');
}
});
for (const harness of ['pi', 'claude-code']) test(`${harness}: PM asks once and the owner answers through the bus`, async t => {
const x = await requestFixture(t, 'guided'), state = x.state;
assert.equal(state.phase, 'discovery');
const pm = bundle(t, 'pm', harness), socket = join(x.f.dataRoot, 'question-tools.sock');
const server = await runner.toolServer({ path: socket, tools: pm.tools, local: {}, client: { call: (verb, args) => x.call('pm', verb, args) } });
t.after(() => new Promise(resolve => server.close(resolve)));
x.pm = pm; x.socket = socket; x.tool = (name, args) => callTool(socket, name, args);
const invoke = harness === 'claude-code' ? mcp(t, x.pm.files.tools, x.socket) : x.tool;
const args = { request: state.request, revision: state.revision, op: 'question-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }] };
const asked = await invoke('intake_question', args);
assert.equal(asked.question.text, 'Which platform?');
assert.deepEqual(asked.question.options, args.options);
assert.equal(asked.question.answer, null);
assert.deepEqual(await invoke('intake_question', args), asked);
for (const role of ['cto', 'coder', 'reviewer', 'chief'])
await assert.rejects(async () => x.call(role, 'request.question', { ...args, op: 'foreign' }), /request-access-refused/);
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='question.asked'").n, 1);
await assert.rejects(x.tool('intake_question', { ...args, text: 'Changed?' }), /request-operation-conflict/);
const answered = await x.call('console', 'request.answer.console', { request: state.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'Web' });
assert.ok(answered.question.answer);
assert.equal(x.store.get('SELECT kind FROM events WHERE id=?', answered.question.answer).kind, 'human.input');
assert.equal((await x.tool('intake_status', { request: state.request })).question.answer, answered.question.answer);
});
test('intake_question refuses malformed arguments and broker phase refusal', async t => {
const x = await setup(t), state = await x.tool('intake_open', { request: x.source.request });
const valid = { request: state.request, revision: state.revision, op: 'q1', text: 'Question?' };
for (const invalid of [
{ ...valid, extra: true }, { ...valid, revision: 0 }, { ...valid, op: '' }, { ...valid, text: '' },
{ ...valid, text: 'a'.repeat(2001) }, { ...valid, text: 'bad\0text' },
{ ...valid, options: [{ key: 'a', text: 'A', extra: true }] },
{ ...valid, options: [{ key: 'a', text: 'A' }, { key: 'a', text: 'B' }] },
{ ...valid, options: Array.from({ length: 9 }, (_, i) => ({ key: `k${i}`, text: 'A' })) },
{ ...valid, options: [{ key: 'a', text: 'a'.repeat(201) }] },
]) await assert.rejects(x.tool('intake_question', invalid), /invalid-request/);
await assert.rejects(x.tool('intake_question', valid), /request-phase-refused/);
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='question.asked'").n, 0);
});
test('intake_question reports an unknown socket outcome without retrying', async t => {
const root = scratch(t), path = join(root, 'lost.sock');
let received = 0;
const server = createServer(socket => socket.on('data', () => { received++; socket.end(); }));
await new Promise(resolve => server.listen(path, resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
await assert.rejects(callTool(path, 'intake_question', { request: 'r', revision: 1, op: 'q', text: 'Q?' }), /outcome.unknown/i);
assert.equal(received, 1);
});
test('intake_question exposes exactly the bounded broker fields', t => {
const schema = bundle(t, 'pm', 'pi').tools.find(t => t.name === 'intake_question').parameters;
assert.deepEqual(Object.keys(schema.properties).sort(), ['op', 'options', 'request', 'revision', 'text']);
assert.deepEqual(schema.required.sort(), ['op', 'request', 'revision', 'text']);
assert.equal(schema.additionalProperties, false);
assert.equal(schema.properties.text.maxLength, 2000);
assert.equal(schema.properties.options.maxItems, 8);
assert.equal(schema.properties.options.items.additionalProperties, false);
assert.equal(schema.properties.options.items.properties.text.maxLength, 200);
});
test('intake_question reports a bus unknown outcome without calling twice', async t => {
const root = scratch(t), path = join(root, 'unknown.sock'), pm = bundle(t, 'pm', 'pi');
let attempts = 0;
const server = await runner.toolServer({ path, tools: pm.tools, local: {}, client: { call: () => {
attempts++; throw Object.assign(new Error('lost response'), { code: 'outcome-unknown' });
} } });
t.after(() => new Promise(resolve => server.close(resolve)));
await assert.rejects(callTool(path, 'intake_question', { request: 'r', revision: 1, op: 'q', text: 'Q?' }), e => /outcome-unknown/.test(e.message) && !/refused/.test(e.message));
assert.equal(attempts, 1);
});
+1 -1
View File
@@ -15,7 +15,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
assert.deepEqual(names(tools), [
"send_message", "request_staff",
"intake_status", "intake_projects", "intake_open", "intake_propose", "advance_request", "submit_prd",
"intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "advance_request", "submit_prd",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
+5
View File
@@ -32,6 +32,11 @@ order. You don't write the product's code and you don't review it.
and autonomous, guided or existing choices, or records a precise setup wait.
The user chooses and confirms; a proposal grants no bootstrap authority.
Check status after the answer or a setup refusal before continuing.
- During discovery, PRD review or planning, use `intake_question` to ask the
owner a bounded question, optionally with choices. Cite the current revision
and a stable operation id. Check `intake_status` if the outcome is unknown;
reuse the same id and exact arguments for an identical retry. The answer is
user input, not PRD approval.
- Break each approved requirement into tasks on the tracker. Every task
you create cites the requirement id it serves, such as `REQ-TASK-1`,
and the human request that asked for it. A task without both is