@infinisys/is-fabric-team 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Spread the evidence gate's parsing across worker threads.
3
+ *
4
+ * One criterion's check is compared with every command line the ledger recorded,
5
+ * and a ledger has several criteria: the product is what saturates one core
6
+ * while every conversation waits behind it. The lines are independent, so they
7
+ * are handed out in batches and the answers are joined back in order.
8
+ *
9
+ * The callers are synchronous — they filter evidence while rendering a gate — so
10
+ * the pool keeps its promises synchronously: the main thread posts each batch,
11
+ * then waits on a shared counter until the workers have answered. That is the
12
+ * same shape the cost-meter ledger writer uses, and it keeps the gate code
13
+ * unchanged apart from the call it makes.
14
+ *
15
+ * @module is-fabric-team/lib/verdict-pool
16
+ */
17
+
18
+ import { Worker, MessageChannel, receiveMessageOnPort } from 'node:worker_threads';
19
+ import { availableParallelism } from 'node:os';
20
+
21
+ import { checkRunIn } from './check-run.js';
22
+
23
+ /** Below this many lines the hand-off costs more than it saves. */
24
+ const MIN_LINES = 64;
25
+
26
+ /** How long the caller waits before falling back to its own thread. */
27
+ const WAIT_MS = 10_000;
28
+
29
+ /** How many workers one host keeps. */
30
+ const POOL_SIZE = Math.max(1, Math.min(4, availableParallelism() - 1));
31
+
32
+ /** How long an idle pool keeps its threads. */
33
+ const IDLE_SHUTDOWN_MS = 60_000;
34
+
35
+ /** The live pool, created on first use. */
36
+ let pool = null;
37
+
38
+ /** Stops the pool once its threads have been idle for a minute. */
39
+ let idleTimer = null;
40
+
41
+ /**
42
+ * How one command line relates to the check, computed here.
43
+ *
44
+ * @param {string} command - recorded command line.
45
+ * @param {string} check - command text the criterion asks for.
46
+ * @returns {'attributable' | 'masked' | 'none'} verdict.
47
+ */
48
+ function verdictInline(command, check) {
49
+ const verdict = checkRunIn(command, check);
50
+ if (!verdict.invoked) return 'none';
51
+ return verdict.attributable ? 'attributable' : 'masked';
52
+ }
53
+
54
+ /**
55
+ * Start the pool.
56
+ *
57
+ * @returns {{ workers: Worker[], signal: Int32Array, pending: number }} state.
58
+ */
59
+ function startPool() {
60
+ const signal = new Int32Array(new SharedArrayBuffer(4));
61
+ const workers = [];
62
+ for (let index = 0; index < POOL_SIZE; index += 1) {
63
+ const worker = new Worker(new URL('./verdict-worker.mjs', import.meta.url), { execArgv: [] });
64
+ worker.unref();
65
+ workers.push(worker);
66
+ }
67
+ idleTimer ??= setInterval(() => stopPool(), IDLE_SHUTDOWN_MS);
68
+ idleTimer.unref?.();
69
+ return { workers, signal, pending: 0 };
70
+ }
71
+
72
+ /**
73
+ * Park this thread until every batch has answered.
74
+ *
75
+ * A parked thread runs no callbacks, so the answers are read straight off each
76
+ * port rather than awaited. The wait itself sleeps in short steps: the workers
77
+ * need the CPU this thread is holding.
78
+ *
79
+ * @param {Array<{ port1: MessagePort, verdicts: string[] | null }>} jobs - outstanding batches.
80
+ * @param {Int32Array} signal - shared word to sleep on.
81
+ * @param {number} until - wall-clock deadline.
82
+ * @returns {number} how many batches were still unanswered.
83
+ */
84
+ function drain(jobs, signal, until) {
85
+ let pending = jobs.length;
86
+ while (pending > 0 && Date.now() < until) {
87
+ for (const job of jobs) {
88
+ if (job.verdicts !== null) continue;
89
+ const received = receiveMessageOnPort(job.port1);
90
+ if (received === undefined) continue;
91
+ job.verdicts = received.message?.verdicts ?? [];
92
+ pending -= 1;
93
+ job.port1.close();
94
+ }
95
+ if (pending > 0) Atomics.wait(signal, 0, Atomics.load(signal, 0), 2);
96
+ }
97
+ return pending;
98
+ }
99
+
100
+ /**
101
+ * Ask the pool for the verdicts of one batch, synchronously.
102
+ *
103
+ * The caller cannot yield — it is filtering evidence mid-render — so the work is
104
+ * cut into slices, posted to the workers, and the answers joined back in order.
105
+ *
106
+ * @param {string[]} commands - command lines, in ledger order.
107
+ * @param {string} check - command text the criterion asks for.
108
+ * @returns {string[] | null} one verdict per line, or null when the pool could not answer.
109
+ */
110
+ function verdictsFromPool(commands, check) {
111
+ if (pool === null) pool = startPool();
112
+ const size = Math.min(pool.workers.length, Math.max(1, Math.ceil(commands.length / 8)));
113
+ const slices = Array.from({ length: size }, () => []);
114
+ commands.forEach((command, index) => {
115
+ slices[Math.floor((index / commands.length) * size)].push(command);
116
+ });
117
+ const jobs = slices
118
+ .filter((slice) => slice.length > 0)
119
+ .map((slice, slot) => {
120
+ const { port1, port2 } = new MessageChannel();
121
+ pool.workers[slot].postMessage({ id: slot, commands: slice, check, port: port2 }, [port2]);
122
+ return { slice, port1, verdicts: null };
123
+ });
124
+ return joinVerdicts(jobs, commands.length);
125
+ }
126
+
127
+ /**
128
+ * Wait for every batch and lay the answers out in the order the caller gave.
129
+ *
130
+ * @param {Array<{ slice: string[], port1: MessagePort, verdicts: string[] | null }>} jobs - batches.
131
+ * @param {number} total - how many lines were submitted.
132
+ * @returns {string[] | null} one verdict per line, or null on timeout.
133
+ */
134
+ function joinVerdicts(jobs, total) {
135
+ const unanswered = drain(jobs, pool.signal, Date.now() + WAIT_MS);
136
+ if (unanswered > 0) return null;
137
+ const verdicts = new Array(total);
138
+ let offset = 0;
139
+ for (const job of jobs) {
140
+ for (let position = 0; position < job.slice.length; position += 1) {
141
+ verdicts[offset + position] = job.verdicts[position] ?? 'none';
142
+ }
143
+ offset += job.slice.length;
144
+ }
145
+ return verdicts;
146
+ }
147
+
148
+ /**
149
+ * The verdicts of every line.
150
+ *
151
+ * @param {string[]} commands - command lines, in ledger order.
152
+ * @param {string} check - command text the criterion asks for.
153
+ * @returns {string[]} one verdict per line.
154
+ */
155
+ export function verdictsOf(commands, check) {
156
+ if (commands.length === 0) return [];
157
+ if (commands.length < MIN_LINES) return commands.map((command) => verdictInline(command, check));
158
+ return verdictsFromPool(commands, check) ?? commands.map((command) => verdictInline(command, check));
159
+ }
160
+
161
+ /**
162
+ * Drop the pool's threads; the next call starts them again.
163
+ *
164
+ * @returns {void}
165
+ */
166
+ export function stopPool() {
167
+ if (idleTimer !== null) {
168
+ clearInterval(idleTimer);
169
+ idleTimer = null;
170
+ }
171
+ if (pool === null) return;
172
+ for (const worker of pool.workers) void worker.terminate();
173
+ pool = null;
174
+ }
175
+
176
+ // The idle timer is started with the pool and stopped with it, so a hot reload
177
+ // that disposes this plugin leaves no interval behind counting down to nothing.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Worker side of the evidence gate: decide how a batch of recorded commands
3
+ * relates to one criterion's check.
4
+ *
5
+ * The main thread splits the ledger's command rows across workers and waits for
6
+ * the answers, so the gate no longer turns one core into the only core. Nothing
7
+ * here touches the filesystem or the ledger: it receives text, parses it, and
8
+ * answers with one verdict per line.
9
+ *
10
+ * @module is-fabric-team/lib/verdict-worker
11
+ */
12
+
13
+ import { parentPort } from 'node:worker_threads';
14
+
15
+ import { checkRunIn } from './check-run.js';
16
+
17
+ /**
18
+ * How one command line relates to the check.
19
+ *
20
+ * @param {string} command - recorded command line.
21
+ * @param {string} check - command text the criterion asks for.
22
+ * @returns {'attributable' | 'masked' | 'none'} verdict.
23
+ */
24
+ function verdictOf(command, check) {
25
+ const verdict = checkRunIn(command, check);
26
+ if (!verdict.invoked) return 'none';
27
+ return verdict.attributable ? 'attributable' : 'masked';
28
+ }
29
+
30
+ parentPort?.on('message', ({ id, commands, check, port }) => {
31
+ const verdicts = (commands ?? []).map((command) => verdictOf(command, check));
32
+ // Answer on the port this batch arrived with when there is one: the caller
33
+ // parks its thread and polls that port, so `parentPort` would never be read.
34
+ (port ?? parentPort)?.postMessage({ id, verdicts });
35
+ });
package/lib/watchdog.js CHANGED
@@ -20,6 +20,7 @@
20
20
  import { randomUUID } from 'node:crypto';
21
21
  import { TEXT_OUTPUT } from './tool-schema.js';
22
22
  import { TeamError } from './team-core.js';
23
+ import { laneStage } from './lanes.js';
23
24
  import { staleReviews } from './open-work.js';
24
25
  import { expireAttention, openAttention, openAttentions } from './attention.js';
25
26
 
@@ -302,6 +303,8 @@ function briefAction({ state, now }) {
302
303
  id: entry.id,
303
304
  title: entry.title,
304
305
  status: entry.status,
306
+ stage: laneStage(state, entry),
307
+ decisionId: entry.decisionId ?? null,
305
308
  createdAt: entry.createdAt,
306
309
  })),
307
310
  activeDecision: state.decisions?.find((entry) => entry.id === state.activeDecisionId) ?? null,
@@ -348,8 +348,8 @@ export function workRecordBody(state, requirement, options = {}) {
348
348
  * @param {object} state - ledger state.
349
349
  * @returns {{ project: string, issue: number, ref: string } | undefined} thread.
350
350
  */
351
- export function trackedThread(state) {
352
- const tracking = activeRequirement(state)?.tracking;
351
+ export function trackedThread(state, requirement = activeRequirement(state)) {
352
+ const tracking = requirement?.tracking;
353
353
  const project = typeof tracking?.project === 'string' ? tracking.project.trim() : '';
354
354
  const issue = Number(tracking?.issue);
355
355
  if (project === '' || !Number.isInteger(issue)) return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@infinisys/is-fabric-team",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Infinity Fabric AI Team: team collaboration mode, local roster and identities, unanimous voting, GitFox tracking and per-role hard boundaries.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,13 +28,14 @@
28
28
  "client.js",
29
29
  "mode.js",
30
30
  "lib/*.js",
31
+ "lib/*.mjs",
31
32
  "cordis.patch.yml",
32
33
  "locale/*.json",
33
34
  "icon.svg"
34
35
  ],
35
36
  "peerDependencies": {
36
37
  "@infinisys/is-settings": "^0.1.0",
37
- "@infinisys/is-devops": "^0.1.0"
38
+ "@infinisys/is-devops": "^0.1.1"
38
39
  },
39
40
  "dsh": {
40
41
  "bundle": {