axmap-cli 1.0.1 → 1.1.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.
- package/CLAUDE.md +12 -8
- package/README.md +3 -2
- package/app/lib/teamview.mjs +15 -2
- package/bin/axmap.mjs +500 -121
- package/mcp/README.md +3 -2
- package/mcp/server.mjs +12 -6
- package/package.json +1 -1
- package/src/invariants.mjs +103 -2
- package/src/protocol.mjs +185 -144
- package/tools/bus.mjs +421 -14
package/src/protocol.mjs
CHANGED
|
@@ -47,66 +47,63 @@ export function activeClaims(claims, now) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* 이 레코드를 **내가(이 세션이) 적었는가.**
|
|
51
|
+
*
|
|
52
|
+
* 🔴 레코드의 임자는 이름 하나가 아니라 **(이름, 세션) 짝**이다.
|
|
53
|
+
*
|
|
54
|
+
* 2026-08-28 사고의 뿌리가 여기였다. 한 PC 에서 AI 도구 창을 둘 띄우면 둘 다
|
|
55
|
+
* `git config user.name` 이 같으므로 장부는 둘을 **한 사람**으로 봤고, 레코드가
|
|
56
|
+
* 이름당 하나뿐이라 **뒤에 온 기록이 앞 세션의 것을 통째로 갈아끼웠다.**
|
|
57
|
+
* 그래서 B 창의 `release` 하나가 A 창이 잡고 있던 경로까지 함께 풀었다.
|
|
58
|
+
*
|
|
59
|
+
* 이름은 **누구인가**이고 세션은 **어느 작업 주체인가**다. 한 사람이 동시에 두
|
|
60
|
+
* 주체일 수 있다. 그래서 **적는 자리를 세션마다 따로 둔다** — 덮을 자리가
|
|
61
|
+
* 아예 없으면 덮어쓰기 사고도 없다.
|
|
62
|
+
*
|
|
63
|
+
* 세션을 모르는 실행들(세션 개념이 없는 셸, 옛 레코드)은 `null` 이라는 한 자리를
|
|
64
|
+
* 함께 쓴다. 실행마다 다른 값을 지어내면 자기가 잡은 것을 자기가 못 반납한다.
|
|
65
|
+
*/
|
|
66
|
+
export function isSameSubject(claim, me, session = null) {
|
|
67
|
+
return claim.agent === me && (claim.session ?? null) === (session ?? null)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 아직 효력이 있는 **이 세션의** claim 을 찾는다. 만료되었으면 null 이다.
|
|
51
72
|
*
|
|
52
73
|
* 만료를 "레코드가 사라짐"이 아니라 "레코드는 남고 효력만 잃음"으로 정의했으므로,
|
|
53
74
|
* 레코드의 존재를 효력으로 착각하면 안 된다.
|
|
54
75
|
* 만료된 내 claim 의 paths 를 다음 claim 에 합치면, 그 사이 남이 정당하게 가져간
|
|
55
76
|
* 경로가 검사 없이 부활한다. 락이 조용히 두 명에게 발급되는 최악의 실패다.
|
|
56
77
|
*/
|
|
57
|
-
export function myActiveClaim(claims, me, now) {
|
|
58
|
-
return activeClaims(claims, now).find((c) => c
|
|
78
|
+
export function myActiveClaim(claims, me, now, session = null) {
|
|
79
|
+
return activeClaims(claims, now).find((c) => isSameSubject(c, me, session)) ?? null
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* 🔴 왜 이름만으로는 부족한가 — 2026-08-28 에 두 번 재현된 사고.
|
|
65
|
-
*
|
|
66
|
-
* 한 PC 에서 Claude Code 세션 두 개가 같은 저장소를 만졌다. 둘 다
|
|
67
|
-
* `git config user.name` 이 `janghyojoon` 이라 장부에서 **한 사람**이었다.
|
|
68
|
-
* `checkOverlap` 은 자기 claim 을 겹침으로 보지 않으므로 세션 B 의 claim 이
|
|
69
|
-
* 그냥 통과했고, 레코드는 이름 하나에 파일 하나라 A 의 것이 B 의 것으로
|
|
70
|
-
* 덮였다. 두 세션이 같은 파일을 동시에 밀었고 장부는 아무 말도 안 했다.
|
|
71
|
-
*
|
|
72
|
-
* 이름은 **누구인가**이고 세션은 **어느 작업 주체인가**다. 한 사람이 동시에
|
|
73
|
-
* 두 주체일 수 있다. 그 둘을 같은 것으로 두면 락이 조용히 두 번 발급된다.
|
|
74
|
-
*
|
|
75
|
-
* ── 왜 "모르면 막는다" 가 아닌가 ──────────────────────────────────────────
|
|
83
|
+
* 같은 **이름**의, 이 세션이 아닌 다른 세션이 잡고 있는 것들.
|
|
76
84
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* same 양쪽 다 알고 같다 → 지금까지와 똑같이 돈다
|
|
82
|
-
* different 양쪽 다 알고 다르다 → **거부한다.** 이것이 위 사고다
|
|
83
|
-
* unverifiable 한쪽이라도 모른다 → 막지 않되 **조용히 넘어가지 않는다**
|
|
84
|
-
*
|
|
85
|
-
* 이 판정은 인증이 아니다. 환경변수를 지우면 `unverifiable` 로 내려온다.
|
|
86
|
-
* 막으려는 것은 악의가 아니라 **아무도 눈치 못 챈 사고**다.
|
|
87
|
-
*
|
|
88
|
-
* @returns {'none'|'same'|'different'|'unverifiable'}
|
|
85
|
+
* 막기 위한 것이 아니라 **말하기 위한** 것이다. 사고의 피해는 "덮어썼다" 가
|
|
86
|
+
* 아니라 "덮어썼는데 아무도 몰랐다" 였다. 이제 덮지는 않지만, 같은 이름이
|
|
87
|
+
* 장부에 두 줄로 서 있다는 사실은 그 자리에서 알려준다.
|
|
89
88
|
*/
|
|
90
|
-
export function
|
|
91
|
-
|
|
92
|
-
const held = prev.session ?? null
|
|
93
|
-
const mine = session ?? null
|
|
94
|
-
if (!held || !mine) return 'unverifiable'
|
|
95
|
-
return held === mine ? 'same' : 'different'
|
|
89
|
+
export function otherSessionClaims(claims, me, now, session = null) {
|
|
90
|
+
return activeClaims(claims, now).filter((c) => c.agent === me && !isSameSubject(c, me, session))
|
|
96
91
|
}
|
|
97
92
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
/**
|
|
94
|
+
* **사람 단위**로 지금 잡고 있는 경로 전부 (세션을 가리지 않는다).
|
|
95
|
+
*
|
|
96
|
+
* pre-commit 검사가 쓰는 자리다. 커밋을 하는 것은 세션이 아니라 사람이고,
|
|
97
|
+
* A 창에서 잡아 B 창에서 커밋하는 것은 이 저장소가 원래 허용하던 흐름이다.
|
|
98
|
+
* 여기까지 세션으로 좁히면 고치려던 것보다 큰 고장이 된다.
|
|
99
|
+
*/
|
|
100
|
+
export function myActivePaths(claims, me, now) {
|
|
101
|
+
const out = new Set()
|
|
102
|
+
for (const c of activeClaims(claims, now)) {
|
|
103
|
+
if (c.agent !== me) continue
|
|
104
|
+
for (const p of c.paths ?? []) out.add(normalizePath(p))
|
|
109
105
|
}
|
|
106
|
+
return [...out]
|
|
110
107
|
}
|
|
111
108
|
|
|
112
109
|
/**
|
|
@@ -117,12 +114,22 @@ function sessionConflictOf(prev, me, session) {
|
|
|
117
114
|
*
|
|
118
115
|
* @returns {{ok: boolean, blocks: Array}} blocks 는 막은 이유의 목록
|
|
119
116
|
*/
|
|
120
|
-
export function checkOverlap({ requested, claims, me, now }) {
|
|
117
|
+
export function checkOverlap({ requested, claims, me, now, session = null }) {
|
|
121
118
|
const want = requested.map(normalizePath)
|
|
122
119
|
const blocks = []
|
|
123
120
|
|
|
124
121
|
for (const c of activeClaims(claims, now)) {
|
|
125
|
-
|
|
122
|
+
// 🔴 **"내 것" 은 이름이 아니라 (이름, 세션) 짝이다.**
|
|
123
|
+
//
|
|
124
|
+
// 예전에는 이름만 봤다. 그래서 한 PC 에서 창을 둘 띄우면 **같은 파일을
|
|
125
|
+
// 둘 다 잡을 수 있었다** — 겹침을 막으려고 만든 검사가 정작 가장 흔한
|
|
126
|
+
// 겹침을 통과시킨 것이다. 사람이 자기 창 둘을 헷갈리는 일은 남의 작업과
|
|
127
|
+
// 부딪히는 일보다 잦다.
|
|
128
|
+
//
|
|
129
|
+
// 이름만 봐도 됐던 것은 한 사람에게 줄이 하나뿐이던 시절의 이야기다.
|
|
130
|
+
// 반납 단위를 세션으로 가르면서 줄이 여럿이 됐으므로 여기도 같이 간다 —
|
|
131
|
+
// 한쪽만 바꾸면 "따로 적히는데 서로 안 막는" 어중간한 상태가 된다.
|
|
132
|
+
if (isSameSubject(c, me, session)) continue // 내 줄과는 겹쳐도 된다 (추가 claim)
|
|
126
133
|
for (const w of want) {
|
|
127
134
|
for (const held of (c.paths ?? []).map(normalizePath)) {
|
|
128
135
|
if (pathsOverlap(w, held)) {
|
|
@@ -151,26 +158,44 @@ export function checkOverlap({ requested, claims, me, now }) {
|
|
|
151
158
|
// 검증기가 같은 로직을 다시 구현하면, 검증하는 대상이 제품이 아니라 검증기가 된다.
|
|
152
159
|
// ---------------------------------------------------------------------------
|
|
153
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 레코드 하나를 장부에 앉힌다.
|
|
163
|
+
*
|
|
164
|
+
* 🔴 **밀어내는 기준이 이름이 아니라 (이름, 세션) 짝이다.** 이름으로 밀어내면
|
|
165
|
+
* 같은 사람의 다른 창이 적어둔 줄이 여기서 사라진다 — 그것이 이 사고였다.
|
|
166
|
+
*/
|
|
154
167
|
function upsert(claims, record) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
const rest = claims.filter((c) => !isSameSubject(c, record.agent, record.session ?? null))
|
|
169
|
+
return sortClaims([...rest, record])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 장부의 줄 순서. 이름이 같으면 세션으로 가른다 — 순서가 흔들리면 diff 가 시끄럽다. */
|
|
173
|
+
function sortClaims(claims) {
|
|
174
|
+
return [...claims].sort((a, b) => {
|
|
175
|
+
if (a.agent !== b.agent) return a.agent < b.agent ? -1 : 1
|
|
176
|
+
const x = a.session ?? ''
|
|
177
|
+
const y = b.session ?? ''
|
|
178
|
+
return x < y ? -1 : x > y ? 1 : 0
|
|
179
|
+
})
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
/**
|
|
161
183
|
* claim 취득. 관문 2 의 판정이 여기서 일어난다.
|
|
162
184
|
*
|
|
163
|
-
* 관문 2
|
|
164
|
-
*
|
|
165
|
-
*
|
|
185
|
+
* 관문 2 가 묻는 것은 하나다 — **남이 잡은 경로와 겹치는가** (`checkOverlap`).
|
|
186
|
+
* 막히면 **아무것도 쓰지 않고 돌아간다.** 판정이 끝나기 전에는 레코드를 만들지 않는다.
|
|
187
|
+
*
|
|
188
|
+
* 🔴 같은 이름의 다른 세션은 **막지 않는다. 대신 자리를 따로 준다.**
|
|
189
|
+
* 예전에는 여기서 거부했다 — 레코드가 이름당 하나뿐이라 그대로 두면 앞 세션의
|
|
190
|
+
* 작업·의도·시작 시각이 덮이기 때문이었다. 그런데 거부는 증상만 막았다.
|
|
191
|
+
* 같은 사람이 창 두 개로 **겹치지 않는 다른 일**을 하는 것까지 통째로 막혀서,
|
|
192
|
+
* 사람은 도구 밖으로 나가거나 이름을 바꿔 달았다.
|
|
166
193
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
194
|
+
* 이제 세션마다 레코드가 따로 적히므로 덮을 자리 자체가 없다. 앞 세션의 줄은
|
|
195
|
+
* 그대로 서 있고, 이 세션은 자기 줄을 새로 만든다. 다만 **조용히 넘어가지는
|
|
196
|
+
* 않는다** — 같은 이름의 다른 줄이 있으면 `otherSessions` 로 돌려준다.
|
|
170
197
|
*
|
|
171
|
-
* @returns {{ok:true, record, claims, hadExpired,
|
|
172
|
-
* |{ok:false, blocks}
|
|
173
|
-
* |{ok:false, blocks:[], sessionConflict}}
|
|
198
|
+
* @returns {{ok:true, record, claims, hadExpired, otherSessions}|{ok:false, blocks}}
|
|
174
199
|
*/
|
|
175
200
|
export function applyClaim({
|
|
176
201
|
claims,
|
|
@@ -182,29 +207,16 @@ export function applyClaim({
|
|
|
182
207
|
intent = null,
|
|
183
208
|
actor = null,
|
|
184
209
|
session = null,
|
|
185
|
-
takeover = false,
|
|
186
210
|
}) {
|
|
187
211
|
const want = [...new Set(requested.map(normalizePath))]
|
|
188
212
|
|
|
189
|
-
const verdict = checkOverlap({ requested: want, claims, me, now })
|
|
213
|
+
const verdict = checkOverlap({ requested: want, claims, me, now, session })
|
|
190
214
|
if (!verdict.ok) return { ok: false, blocks: verdict.blocks }
|
|
191
215
|
|
|
192
|
-
// 반드시 "효력이 남은"
|
|
216
|
+
// 반드시 "효력이 남은 **이 세션의**" claim 하고만 합친다. 만료된 것과 합치면
|
|
193
217
|
// 그 사이 남이 가져간 경로가 관문 2 를 거치지 않고 부활한다.
|
|
194
|
-
const prev = myActiveClaim(claims, me, now)
|
|
195
|
-
const hadExpired = !prev && claims.some((c) => c
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 경로가 안 겹쳐도 막는다.
|
|
199
|
-
*
|
|
200
|
-
* 겹칠 때만 막으면 안 되는 이유: 이름당 레코드는 하나뿐이라 경로가 안 겹쳐도
|
|
201
|
-
* 저쪽의 `task`·`intent`·`since`·TTL 이 이쪽 값으로 덮인다. 장부를 읽는 사람은
|
|
202
|
-
* 저 세션이 무엇을 왜 하고 있었는지를 잃는다 — 그게 "기록이 사라졌다" 의 실체다.
|
|
203
|
-
*/
|
|
204
|
-
const sv = sessionVerdict(prev, session)
|
|
205
|
-
if (sv === 'different' && !takeover) {
|
|
206
|
-
return { ok: false, blocks: [], sessionConflict: sessionConflictOf(prev, me, session) }
|
|
207
|
-
}
|
|
218
|
+
const prev = myActiveClaim(claims, me, now, session)
|
|
219
|
+
const hadExpired = !prev && claims.some((c) => isSameSubject(c, me, session))
|
|
208
220
|
|
|
209
221
|
const record = {
|
|
210
222
|
agent: me,
|
|
@@ -214,51 +226,75 @@ export function applyClaim({
|
|
|
214
226
|
// 프로토콜 판정에는 쓰이지 않는다 — 표시용 정보다.
|
|
215
227
|
actor: actor ?? prev?.actor ?? null,
|
|
216
228
|
/**
|
|
217
|
-
* 이 레코드를
|
|
229
|
+
* 이 레코드를 적은 세션. **레코드를 가르는 키의 절반이다** (actor 와 다른 점이다).
|
|
218
230
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* 다른 세션이 아무 말 없이 덮을 수 있게 된다 — 막으려던 그 자리로 돌아간다.
|
|
231
|
+
* `prev` 는 이미 같은 세션의 것만 찾아온 것이라 여기서 갈릴 일이 없다.
|
|
232
|
+
* 세션을 모르면 `null` — 그것도 하나의 자리다 (isSameSubject 참고).
|
|
222
233
|
*/
|
|
223
|
-
session: session ??
|
|
234
|
+
session: session ?? null,
|
|
224
235
|
since: new Date(now).toISOString(),
|
|
225
236
|
ttlMs,
|
|
226
237
|
paths: [...new Set([...(prev?.paths ?? []).map(normalizePath), ...want])].sort(),
|
|
227
238
|
}
|
|
228
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
record,
|
|
242
|
+
claims: upsert(claims, record),
|
|
243
|
+
hadExpired,
|
|
244
|
+
otherSessions: otherSessionClaims(claims, me, now, session),
|
|
245
|
+
}
|
|
229
246
|
}
|
|
230
247
|
|
|
231
248
|
/**
|
|
232
249
|
* 반납. drop 이 비어 있으면 전부 반납한다.
|
|
233
250
|
* 만료된 레코드도 반납할 수 있다 (단순 정리이므로 관문 2 가 필요 없다).
|
|
234
251
|
*
|
|
235
|
-
* 🔴
|
|
236
|
-
*
|
|
237
|
-
*
|
|
252
|
+
* 🔴 **반납의 단위는 세션이다.**
|
|
253
|
+
*
|
|
254
|
+
* `release` 는 경로를 다 빼면 레코드를 지운다. 이름만 보고 지우면 같은 PC 의
|
|
255
|
+
* 다른 창이 잡고 있던 것까지 함께 풀린다 — 저쪽은 자기가 아직 쥐고 있다고
|
|
256
|
+
* 믿는데 장부는 비어 있고, 그 자리에 다른 사람이 들어온다. 2026-08-28.
|
|
238
257
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
258
|
+
* 그래서 여기서 푸는 것은 **이 세션이 적은 줄뿐**이다. 다른 세션의 것까지
|
|
259
|
+
* 풀어야 할 때가 있으므로(창이 죽어 두고 간 것 등) 문을 하나 둔다 —
|
|
260
|
+
* `allSessions`. 문이 없으면 사람은 시스템 밖으로 나가고, 그때 하는 일은
|
|
261
|
+
* 장부 파일을 손으로 지우는 것이라 아무 기록도 남지 않는다.
|
|
262
|
+
*
|
|
263
|
+
* @returns {{claims, records, removed, unheld, hadNothing, otherSessions}}
|
|
264
|
+
* records 경로가 남아 다시 적을 레코드들
|
|
265
|
+
* removed 경로가 하나도 안 남아 지울 레코드들
|
|
242
266
|
*/
|
|
243
|
-
export function applyRelease({ claims, me, drop, session = null,
|
|
244
|
-
const mine = claims.
|
|
245
|
-
if (!mine)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
267
|
+
export function applyRelease({ claims, me, drop, session = null, allSessions = false }) {
|
|
268
|
+
const mine = claims.filter((c) => (allSessions ? c.agent === me : isSameSubject(c, me, session)))
|
|
269
|
+
if (!mine.length) {
|
|
270
|
+
return {
|
|
271
|
+
claims,
|
|
272
|
+
records: [],
|
|
273
|
+
removed: [],
|
|
274
|
+
unheld: [],
|
|
275
|
+
hadNothing: true,
|
|
276
|
+
// 이름은 맞는데 세션이 달라서 못 찾은 것인지를 부르는 쪽이 말할 수 있어야 한다.
|
|
277
|
+
// 안 그러면 "반납할 게 없다" 를 보고 이름을 고치는 엉뚱한 처방으로 간다.
|
|
278
|
+
otherSessions: claims.filter((c) => c.agent === me),
|
|
279
|
+
}
|
|
250
280
|
}
|
|
251
281
|
|
|
252
|
-
const held = mine.paths.map(normalizePath)
|
|
253
282
|
const want = [...new Set(drop.map(normalizePath))]
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
283
|
+
const heldAll = new Set(mine.flatMap((c) => (c.paths ?? []).map(normalizePath)))
|
|
284
|
+
const unheld = want.filter((p) => !heldAll.has(p))
|
|
285
|
+
|
|
286
|
+
const records = []
|
|
287
|
+
const removed = []
|
|
288
|
+
for (const c of mine) {
|
|
289
|
+
const held = (c.paths ?? []).map(normalizePath)
|
|
290
|
+
const remaining = want.length ? held.filter((p) => !want.includes(p)) : []
|
|
291
|
+
if (remaining.length) records.push({ ...c, paths: remaining })
|
|
292
|
+
else removed.push(c)
|
|
259
293
|
}
|
|
260
|
-
|
|
261
|
-
|
|
294
|
+
|
|
295
|
+
let next = claims.filter((c) => !mine.includes(c))
|
|
296
|
+
for (const r of records) next = upsert(next, r)
|
|
297
|
+
return { claims: sortClaims(next), records, removed, unheld, hadNothing: false, otherSessions: [] }
|
|
262
298
|
}
|
|
263
299
|
|
|
264
300
|
/**
|
|
@@ -266,20 +302,24 @@ export function applyRelease({ claims, me, drop, session = null, takeover = fals
|
|
|
266
302
|
* 만료된 claim 은 연장할 수 없다. renew 는 관문 2 를 거치지 않으므로,
|
|
267
303
|
* 만료 이후를 허용하면 그 사이 남이 가져간 경로를 검사 없이 되찾게 된다.
|
|
268
304
|
*
|
|
269
|
-
* 🔴
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* 있는지는 `session` 으로 돌려주고, 부르는 쪽이 그것을 말한다.
|
|
305
|
+
* 🔴 **늘리는 것도 이 세션의 것뿐이다.** 남의 세션 줄의 수명을 모르고 늘리면
|
|
306
|
+
* 그 경로는 아무도 안 쓰는데 계속 막혀 있게 된다. 다른 세션의 것을 늘리려면
|
|
307
|
+
* `--session <그 세션 id>` 로 그 세션이라고 말하고 늘린다.
|
|
273
308
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
309
|
+
* 못 찾았을 때 같은 이름의 다른 줄이 있으면 `otherSessions` 로 알려준다 —
|
|
310
|
+
* "claim 이 없다" 와 "세션이 달라 못 찾았다" 는 처방이 다르다.
|
|
276
311
|
*/
|
|
277
312
|
export function applyRenew({ claims, me, now, ttlMs, session = null }) {
|
|
278
|
-
const mine = myActiveClaim(claims, me, now)
|
|
279
|
-
if (!mine)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
313
|
+
const mine = myActiveClaim(claims, me, now, session)
|
|
314
|
+
if (!mine) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: 'expired-or-missing',
|
|
318
|
+
otherSessions: otherSessionClaims(claims, me, now, session),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const record = { ...mine, since: new Date(now).toISOString(), ttlMs }
|
|
322
|
+
return { ok: true, record, claims: upsert(claims, record) }
|
|
283
323
|
}
|
|
284
324
|
|
|
285
325
|
// ---------------------------------------------------------------------------
|
|
@@ -380,37 +420,38 @@ export function shortSession(id) {
|
|
|
380
420
|
}
|
|
381
421
|
|
|
382
422
|
/**
|
|
383
|
-
* "같은
|
|
423
|
+
* "같은 이름의 다른 세션도 뭔가를 잡고 있다" 를 사람과 AI 가 함께 읽는 형태로.
|
|
424
|
+
*
|
|
425
|
+
* 🔴 **막는 말이 아니라 알리는 말이다.** 세션마다 레코드가 따로 적히므로 이제
|
|
426
|
+
* 서로 덮지 않는다. 그래도 말은 한다 — 이 사고의 피해는 "덮어썼다" 가 아니라
|
|
427
|
+
* **"덮어썼는데 아무도 몰랐다"** 였다. 막을 이유가 없을 때 할 수 있는 최소한은
|
|
428
|
+
* 무엇이 일어나고 있는지를 그 자리에서 보여주는 것이다.
|
|
384
429
|
*
|
|
385
|
-
* `
|
|
386
|
-
*
|
|
387
|
-
*
|
|
430
|
+
* `overlap` 이 있으면 먼저 말한다. 같은 이름이면 겹침 검사가 서로를 막지 않으므로
|
|
431
|
+
* (`checkOverlap` 의 자기 claim 분기) **같은 파일을 두 창이 동시에 고칠 수 있다.**
|
|
432
|
+
* 그것만은 사람이 알고 해야 한다.
|
|
388
433
|
*/
|
|
389
|
-
export function
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
lines.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
lines.push(' - 저 세션이 끝난 것이 확실하면 TTL 만료를 기다린다')
|
|
411
|
-
lines.push(` - 저 세션이 확실히 나 자신이면 --takeover 를 붙인다`)
|
|
412
|
-
lines.push(' 레코드를 이 세션으로 넘겨받습니다. 잡혀 있던 경로는 하나도 안 없어집니다.')
|
|
413
|
-
return lines.join('\n')
|
|
434
|
+
export function formatOtherSessions(others, requested, now) {
|
|
435
|
+
const want = (requested ?? []).map(normalizePath)
|
|
436
|
+
const lines = []
|
|
437
|
+
for (const c of others) {
|
|
438
|
+
const held = (c.paths ?? []).map(normalizePath)
|
|
439
|
+
const overlap = want.filter((w) => held.some((h) => pathsOverlap(w, h)))
|
|
440
|
+
lines.push(
|
|
441
|
+
` 세션 ${shortSession(c.session)}${c.task ? ` [${c.task}]` : ''}` +
|
|
442
|
+
`${c.intent ? ` "${c.intent}"` : ''} 경로 ${held.length}개 · ${humanDuration(claimExpiresAt(c) - now)} 남음`,
|
|
443
|
+
)
|
|
444
|
+
if (overlap.length) {
|
|
445
|
+
lines.push(` 🔴 겹칩니다: ${overlap.join(' ')} — 같은 이름이라 겹침 검사가 서로를 막지 않습니다`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!lines.length) return ''
|
|
449
|
+
return [
|
|
450
|
+
'알림: 같은 이름의 다른 세션도 잡고 있는 것이 있습니다.',
|
|
451
|
+
...lines,
|
|
452
|
+
' 장부에는 세션마다 따로 적히므로 서로 덮지 않습니다.',
|
|
453
|
+
' release 는 이 세션이 잡은 것만 풉니다 (전부 풀려면 --all-sessions).',
|
|
454
|
+
].join('\n')
|
|
414
455
|
}
|
|
415
456
|
|
|
416
457
|
// ---------------------------------------------------------------------------
|