@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 {
|
|
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
|
|
67
|
-
* sequence. Legacy items (`w-YYYY-MM-DD-NNN` with no author
|
|
68
|
-
* do not count toward any author's sequence — each writer
|
|
69
|
-
* 001 on the day writer-scoping takes effect and existing
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
77
|
-
const
|
|
78
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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