Severity and category
Severity: High correctness and process-lifecycle defect
Category: Command completion / late output / descendant ownership
Problem and intended outcome
Pinchos can report a command as successfully completed before the full command session it owns has actually ended.
When the shell exits, CommandExecutionEngine.run records a CommandExecution immediately. If members of the same process group are still alive, the engine returns that execution plus a LingeringProcess. CommandRunner.finishActiveRun() then returns .completed(execution) to the caller while retaining the lingering process internally.
This creates several externally visible inconsistencies:
ManagedItem.refresh() commits the shell's current stdout as the item's last successful value as soon as it receives .exited(code: 0).
- A background descendant can write additional stdout after the shell exits.
CommandRunner eventually updates its internal lastExecution, but ManagedItem.lastSuccessfulOutput, formatted title, tooltip output, and lastUpdatedAt are never updated from that final stdout.
- If the lingering group reaches the command timeout and is killed,
LingeringProcess.currentExecution() keeps the original .exited(code: 0) terminal reason. Diagnostics can therefore record a successful shell exit even though Pinchos terminated the remaining owned work for timeout.
pinchos run <item> treats the preliminary shell result as final, prints it, returns its exit code, and then the top-level executable calls Darwin.exit. A same-group background descendant can outlive the CLI because the local runner still had a lingering session when the process exited.
The intended outcome is a coherent command-session contract. A caller must be able to distinguish the shell's exit from the final owned session result, and Pinchos must not publish final success, final output, timeout status, or CLI exit until all same-group descendants and output pipes are settled or deliberately cancelled.
Current evidence
Relevant implementation in Sources/PinchosCore/CommandExecution.swift:
- The engine computes
terminalAt and execution when the first process/timeout/cancellation event wins.
keepProcessGroup = controller.hasMembers() allows the engine to return while descendants remain.
LingeringProcess stores the preliminary execution and collectors.
currentExecution() refreshes output snapshots but preserves execution.terminalReason and execution.duration.
CommandRunner.finishActiveRun() returns .completed(completedExecution) even when it appends a still-live LingeringProcess.
settleLingeringProcesses() updates only CommandRunner.lastExecution after the group disappears.
Relevant implementation in Sources/pinchos/ManagedItem.swift:
refresh() treats any returned .exited(code: 0) as final success.
- It copies stdout into
lastSuccessfulOutput, updates lastUpdatedAt, computes the title, and schedules staleness.
- There is no subscription/callback for the later settled execution.
Relevant implementation in Sources/pinchos/PinchosCLI.swift and main.swift:
runItem maps the preliminary execution directly to output and an exit code.
- CLI mode exits the entire process after
PinchosCLI.run returns.
Existing tests verify that late stderr eventually appears in CommandRunner.snapshot(), but they do not prove that the menu-bar value, timeout classification, or CLI lifetime uses the definitive session result.
Required session semantics
Define and document a session as the shell process, all same-process-group descendants, and both captured output pipes.
The final result should be emitted only when one of these conditions is true:
- The shell and all owned descendants have exited and stdout/stderr are fully drained.
- The configured timeout ended the session and cleanup reached its bounded terminal state.
- Cancellation ended the session and cleanup reached its bounded terminal state.
- Launch failed before a session was established.
A preliminary shell-exit event may still be exposed internally for UI feedback, but it must not be confused with final success. If Pinchos intentionally continues to display “running” while descendants exist, the eventual final event must update the item's output, timestamps, status, and diagnostics exactly once.
Timeout duration should describe the owned session, not only the shell leader. If background work outlives the shell and reaches the timeout, the final reason must be .timedOut rather than .exited(0).
Bounded scope and non-goals
In scope:
- Establish a definitive command-session completion API.
- Preserve late stdout and stderr through final pipe closure.
- Correctly classify timeout/cancellation that occurs after shell exit.
- Ensure
ManagedItem commits success and lastUpdatedAt from the final session result.
- Ensure late final output can update the rendered title and tooltip.
- Ensure
pinchos run waits for, times out, or cancels the full session before exiting.
- Preserve no-overlap behavior for the entire session lifetime.
- Preserve bounded retained output and skipped-refresh diagnostics.
- Add deterministic unit and executable-level tests.
Explicit non-goals:
Testable acceptance criteria
Likely implementation areas
Sources/PinchosCore/CommandExecution.swift
- split preliminary shell status from definitive session result
- carry final termination reason and duration through lingering settlement
- expose a final completion awaitable/event to callers
Sources/pinchos/ManagedItem.swift
- update state only from the definitive result, or consume both preliminary and final events deliberately
Sources/pinchos/PinchosCLI.swift
Sources/pinchos/main.swift
Tests/PinchosCoreTests/CommandExecutionTests.swift
Tests/PinchosCoreTests/CommandRunnerTests.swift
Tests/pinchosTests/RecoveryLifecycleTests.swift
Tests/pinchosTests/PinchosCLITests.swift
- README command-lifecycle documentation
Verification plan
- Add deterministic commands that exit the shell and then emit delayed stdout/stderr from same-group children.
- Assert preliminary and final states separately if both remain part of the API.
- Add late timeout and late cancellation cases after shell exit.
- Exercise the same fixtures through headless
ManagedItem and the built CLI executable.
- Verify title, tooltip, status, timestamps, final reason, output byte counts, and child-process absence.
- Stress repeated runs where descendants finish just before and just after timeout.
- Run the full suite and release build.
- Manually inspect a release app item whose background child changes the final visible value.
Risks and dependencies
Waiting for full owned-session completion may expose commands that accidentally background long-lived work. That is correct: such an item is still consuming resources owned by Pinchos and must remain running until it exits or times out.
The API should make the shell/session distinction explicit rather than hiding another settlement callback inside snapshot(). Silent internal mutation after callers were told a run was complete is the root design problem.
Severity and category
Severity: High correctness and process-lifecycle defect
Category: Command completion / late output / descendant ownership
Problem and intended outcome
Pinchos can report a command as successfully completed before the full command session it owns has actually ended.
When the shell exits,
CommandExecutionEngine.runrecords aCommandExecutionimmediately. If members of the same process group are still alive, the engine returns that execution plus aLingeringProcess.CommandRunner.finishActiveRun()then returns.completed(execution)to the caller while retaining the lingering process internally.This creates several externally visible inconsistencies:
ManagedItem.refresh()commits the shell's current stdout as the item's last successful value as soon as it receives.exited(code: 0).CommandRunnereventually updates its internallastExecution, butManagedItem.lastSuccessfulOutput, formatted title, tooltip output, andlastUpdatedAtare never updated from that final stdout.LingeringProcess.currentExecution()keeps the original.exited(code: 0)terminal reason. Diagnostics can therefore record a successful shell exit even though Pinchos terminated the remaining owned work for timeout.pinchos run <item>treats the preliminary shell result as final, prints it, returns its exit code, and then the top-level executable callsDarwin.exit. A same-group background descendant can outlive the CLI because the local runner still had a lingering session when the process exited.The intended outcome is a coherent command-session contract. A caller must be able to distinguish the shell's exit from the final owned session result, and Pinchos must not publish final success, final output, timeout status, or CLI exit until all same-group descendants and output pipes are settled or deliberately cancelled.
Current evidence
Relevant implementation in
Sources/PinchosCore/CommandExecution.swift:terminalAtandexecutionwhen the first process/timeout/cancellation event wins.keepProcessGroup = controller.hasMembers()allows the engine to return while descendants remain.LingeringProcessstores the preliminaryexecutionand collectors.currentExecution()refreshes output snapshots but preservesexecution.terminalReasonandexecution.duration.CommandRunner.finishActiveRun()returns.completed(completedExecution)even when it appends a still-liveLingeringProcess.settleLingeringProcesses()updates onlyCommandRunner.lastExecutionafter the group disappears.Relevant implementation in
Sources/pinchos/ManagedItem.swift:refresh()treats any returned.exited(code: 0)as final success.lastSuccessfulOutput, updateslastUpdatedAt, computes the title, and schedules staleness.Relevant implementation in
Sources/pinchos/PinchosCLI.swiftandmain.swift:runItemmaps the preliminary execution directly to output and an exit code.PinchosCLI.runreturns.Existing tests verify that late stderr eventually appears in
CommandRunner.snapshot(), but they do not prove that the menu-bar value, timeout classification, or CLI lifetime uses the definitive session result.Required session semantics
Define and document a session as the shell process, all same-process-group descendants, and both captured output pipes.
The final result should be emitted only when one of these conditions is true:
A preliminary shell-exit event may still be exposed internally for UI feedback, but it must not be confused with final success. If Pinchos intentionally continues to display “running” while descendants exist, the eventual final event must update the item's output, timestamps, status, and diagnostics exactly once.
Timeout duration should describe the owned session, not only the shell leader. If background work outlives the shell and reaches the timeout, the final reason must be
.timedOutrather than.exited(0).Bounded scope and non-goals
In scope:
ManagedItemcommits success andlastUpdatedAtfrom the final session result.pinchos runwaits for, times out, or cancels the full session before exiting.Explicit non-goals:
Testable acceptance criteria
ManagedItemultimately renders the final late value, updates{output}andlastUpdatedAt, and does not remain stuck on the preliminary value..timedOut, not.exited(code: 0)..cancelledand removes every owned descendant.runningor another explicitly documented non-final state while owned descendants exist; it does not mark a final success early.on_error = "keep_last"andreplaceapply to the definitive result.pinchos run <item>does not exit while the runner still owns a process group or output drain.swift testandswift build -c releasepass.Likely implementation areas
Sources/PinchosCore/CommandExecution.swiftSources/pinchos/ManagedItem.swiftSources/pinchos/PinchosCLI.swiftSources/pinchos/main.swiftTests/PinchosCoreTests/CommandExecutionTests.swiftTests/PinchosCoreTests/CommandRunnerTests.swiftTests/pinchosTests/RecoveryLifecycleTests.swiftTests/pinchosTests/PinchosCLITests.swiftVerification plan
ManagedItemand the built CLI executable.Risks and dependencies
Waiting for full owned-session completion may expose commands that accidentally background long-lived work. That is correct: such an item is still consuming resources owned by Pinchos and must remain running until it exits or times out.
The API should make the shell/session distinction explicit rather than hiding another settlement callback inside
snapshot(). Silent internal mutation after callers were told a run was complete is the root design problem.