@stravica-ai/digital-operator-pa 0.1.22 → 0.1.23

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.
@@ -24,6 +24,24 @@ export async function writeJson(path, value) {
24
24
  await writeFile(path, body, 'utf8');
25
25
  }
26
26
 
27
+ /**
28
+ * Write a JSON file with O_CREAT|O_EXCL semantics. Refuses to overwrite
29
+ * an existing file (throws a Node error with `code === 'EEXIST'`).
30
+ *
31
+ * Same shape rules as `writeJson`: 2-space indent, trailing newline,
32
+ * parent directories created on demand. Callers that follow an
33
+ * allocate-then-write pattern use this to turn a same-author write race
34
+ * into an EEXIST they can retry at NNN+1, rather than the plain flag
35
+ * `'w'` behaviour where the second write silently clobbered the first.
36
+ * Same primitive `escalate.mjs` in `librarian-pr-intake` uses for the
37
+ * librarian's own allocator — the two writers now share one race model.
38
+ */
39
+ export async function writeJsonExclusive(path, value) {
40
+ await mkdir(dirname(path), { recursive: true });
41
+ const body = JSON.stringify(value, null, 2) + '\n';
42
+ await writeFile(path, body, { encoding: 'utf8', flag: 'wx' });
43
+ }
44
+
27
45
  /**
28
46
  * Ensure a directory exists. Returns the absolute path.
29
47
  */
@@ -26,7 +26,22 @@ import {
26
26
  } from '../../../engineering-pa-core/src/workItemId/index.mjs';
27
27
 
28
28
  import { getRepoRoot } from './repo-root.mjs';
29
- import { readJson, writeJson, ensureDir, listJsonFiles, today } from './io.mjs';
29
+ import {
30
+ readJson,
31
+ writeJson,
32
+ writeJsonExclusive,
33
+ ensureDir,
34
+ listJsonFiles,
35
+ today,
36
+ } from './io.mjs';
37
+
38
+ // Upper bound on the O_CREAT|O_EXCL retry loop in `createWork`. Same
39
+ // value the librarian-pr-intake allocator uses (`escalate.mjs`); both
40
+ // close the same class of same-author race between allocator scan and
41
+ // exclusive write. 99 is comfortably above any realistic burst — 999 is
42
+ // the per-author-per-day NNN ceiling so a runaway loop still terminates
43
+ // well below it.
44
+ const MAX_ALLOCATE_RETRIES = 99;
30
45
 
31
46
  const STATUSES = new Set(['draft', 'planned', 'in-progress', 'blocked', 'done', 'archived']);
32
47
 
@@ -63,29 +78,22 @@ function validatePriority(priority) {
63
78
  }
64
79
 
65
80
  /**
66
- * Highest NNN already allocated for `dateStr` in THIS author's per-day
67
- * sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author segment)
68
- * do not count toward any author's sequence — each writer starts from
69
- * 001 on the day writer-scoping takes effect and existing legacy items
70
- * simply occupy a separate, older space.
81
+ * Highest NNN currently visible on disk for `dateStr` in THIS author's
82
+ * per-day sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author
83
+ * segment) do not count toward any author's sequence — each writer
84
+ * starts from 001 on the day writer-scoping takes effect and existing
85
+ * legacy items simply occupy a separate, older space.
71
86
  *
72
- * If 999 is already taken, throws — callers should treat as an
73
- * operational failure and report. In practice this is unreachable
74
- * because NNN is per-author-per-day, not shared.
87
+ * The value is a starting point for `createWork`'s allocate-write-retry
88
+ * loop, not a final answer: between the scan and the exclusive write,
89
+ * another same-author `pa work create` may have landed a same-NNN file
90
+ * (same-author write race, structurally possible when two operator
91
+ * sessions run concurrently on shared work/). The O_CREAT|O_EXCL retry
92
+ * in `createWork` is what closes that residual race.
75
93
  */
76
- async function nextWorkNumber(repoRoot, dateStr, author) {
77
- const dir = join(repoRoot, 'work');
78
- const files = await listJsonFiles(dir);
79
- const max = maxNForAuthorOnDate(files, dateStr, author);
80
- const next = max + 1;
81
- if (next > 999) {
82
- const err = new Error(
83
- `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
84
- );
85
- err.exitCode = 2;
86
- throw err;
87
- }
88
- return next;
94
+ async function scanMaxNInWorkDir(workDir, dateStr, author) {
95
+ const files = await listJsonFiles(workDir);
96
+ return maxNForAuthorOnDate(files, dateStr, author);
89
97
  }
90
98
 
91
99
  /**
@@ -101,6 +109,15 @@ async function nextWorkNumber(repoRoot, dateStr, author) {
101
109
  * with an actionable message naming all three sources — a mint with no
102
110
  * author identity is the exact defect R1 exists to close, so we refuse
103
111
  * to fall back to any hard-coded default.
112
+ *
113
+ * Write is O_CREAT|O_EXCL via `writeJsonExclusive`. If two same-author
114
+ * `createWork` invocations race (same estate, same day, two operator
115
+ * sessions), the allocator scan may hand both callers the same base
116
+ * NNN; the first exclusive write lands and the second gets EEXIST,
117
+ * which the retry loop below resolves by trying NNN+1, NNN+2, ... up
118
+ * to `MAX_ALLOCATE_RETRIES`. Cross-writer collisions (different author
119
+ * tokens) are already structurally impossible after R1
120
+ * (w-2026-08-12-001) — this closes the residual same-author window.
104
121
  */
105
122
  export async function createWork(opts) {
106
123
  if (!opts.title) {
@@ -127,28 +144,48 @@ export async function createWork(opts) {
127
144
  await ensureDir(workDir);
128
145
 
129
146
  const dateStr = today();
130
- const nnn = await nextWorkNumber(repoRoot, dateStr, author);
131
- const id = buildWorkItemId({ date: dateStr, author, nnn });
132
- const path = join(workDir, `${id}.json`);
133
-
134
- const item = {
135
- id,
136
- projectId: opts.projectId || null,
137
- title: opts.title,
138
- category: opts.category || 'chore',
139
- status: 'draft',
140
- priority: opts.priority == null ? 2 : opts.priority,
141
- owner: opts.owner || 'system-architect',
142
- assignee: opts.assignee || opts.owner || 'system-architect',
143
- created: dateStr,
144
- updated: dateStr,
145
- completedAt: null,
146
- summary: opts.summary || '',
147
- blocked: null,
148
- links: [],
149
- };
150
- await writeJson(path, item);
151
- return { id, path };
147
+ const baseN = await scanMaxNInWorkDir(workDir, dateStr, author);
148
+
149
+ for (let offset = 1; offset <= MAX_ALLOCATE_RETRIES; offset += 1) {
150
+ const nnn = baseN + offset;
151
+ if (nnn > 999) {
152
+ const err = new Error(
153
+ `no free work-item slot for ${dateStr} in author=${author} sequence (NNN exhausted)`,
154
+ );
155
+ err.exitCode = 2;
156
+ throw err;
157
+ }
158
+ const id = buildWorkItemId({ date: dateStr, author, nnn });
159
+ const path = join(workDir, `${id}.json`);
160
+ const item = {
161
+ id,
162
+ projectId: opts.projectId || null,
163
+ title: opts.title,
164
+ category: opts.category || 'chore',
165
+ status: 'draft',
166
+ priority: opts.priority == null ? 2 : opts.priority,
167
+ owner: opts.owner || 'system-architect',
168
+ assignee: opts.assignee || opts.owner || 'system-architect',
169
+ created: dateStr,
170
+ updated: dateStr,
171
+ completedAt: null,
172
+ summary: opts.summary || '',
173
+ blocked: null,
174
+ links: [],
175
+ };
176
+ try {
177
+ await writeJsonExclusive(path, item);
178
+ return { id, path };
179
+ } catch (e) {
180
+ if (e && e.code === 'EEXIST') continue;
181
+ throw e;
182
+ }
183
+ }
184
+ const err = new Error(
185
+ `pa work create: gave up after ${MAX_ALLOCATE_RETRIES} EEXIST retries from base NNN ${baseN + 1} in ${dateStr} author=${author}`,
186
+ );
187
+ err.exitCode = 2;
188
+ throw err;
152
189
  }
153
190
 
154
191
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",